cite-agent 1.3.7__py3-none-any.whl → 1.3.9__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.
- cite_agent/__version__.py +1 -1
- cite_agent/cli.py +180 -7
- cite_agent/conversation_archive.py +152 -0
- cite_agent/enhanced_ai_agent.py +1299 -180
- {cite_agent-1.3.7.dist-info → cite_agent-1.3.9.dist-info}/METADATA +1 -1
- {cite_agent-1.3.7.dist-info → cite_agent-1.3.9.dist-info}/RECORD +10 -9
- {cite_agent-1.3.7.dist-info → cite_agent-1.3.9.dist-info}/WHEEL +0 -0
- {cite_agent-1.3.7.dist-info → cite_agent-1.3.9.dist-info}/entry_points.txt +0 -0
- {cite_agent-1.3.7.dist-info → cite_agent-1.3.9.dist-info}/licenses/LICENSE +0 -0
- {cite_agent-1.3.7.dist-info → cite_agent-1.3.9.dist-info}/top_level.txt +0 -0
cite_agent/enhanced_ai_agent.py
CHANGED
|
@@ -17,13 +17,15 @@ from importlib import resources
|
|
|
17
17
|
|
|
18
18
|
import aiohttp
|
|
19
19
|
from datetime import datetime, timezone
|
|
20
|
-
from typing import Dict, Any, List, Optional, Tuple
|
|
20
|
+
from typing import Dict, Any, List, Optional, Tuple, Set
|
|
21
21
|
from urllib.parse import urlparse
|
|
22
22
|
from dataclasses import dataclass, field
|
|
23
23
|
from pathlib import Path
|
|
24
|
+
import platform
|
|
24
25
|
|
|
25
26
|
from .telemetry import TelemetryManager
|
|
26
27
|
from .setup_config import DEFAULT_QUERY_LIMIT
|
|
28
|
+
from .conversation_archive import ConversationArchive
|
|
27
29
|
|
|
28
30
|
# Suppress noise
|
|
29
31
|
logging.basicConfig(level=logging.ERROR)
|
|
@@ -89,6 +91,7 @@ class EnhancedNocturnalAgent:
|
|
|
89
91
|
from .workflow import WorkflowManager
|
|
90
92
|
self.workflow = WorkflowManager()
|
|
91
93
|
self.last_paper_result = None # Track last paper mentioned for "save that"
|
|
94
|
+
self.archive = ConversationArchive()
|
|
92
95
|
|
|
93
96
|
# File context tracking (for pronoun resolution and multi-turn)
|
|
94
97
|
self.file_context = {
|
|
@@ -98,6 +101,7 @@ class EnhancedNocturnalAgent:
|
|
|
98
101
|
'recent_dirs': [], # Last 5 directories
|
|
99
102
|
'current_cwd': None, # Track shell's current directory
|
|
100
103
|
}
|
|
104
|
+
self._is_windows = os.name == "nt"
|
|
101
105
|
try:
|
|
102
106
|
self.per_user_token_limit = int(os.getenv("GROQ_PER_USER_TOKENS", 50000))
|
|
103
107
|
except (TypeError, ValueError):
|
|
@@ -141,6 +145,24 @@ class EnhancedNocturnalAgent:
|
|
|
141
145
|
self._health_ttl = 30.0
|
|
142
146
|
self._recent_sources: List[Dict[str, Any]] = []
|
|
143
147
|
|
|
148
|
+
def _remove_expired_temp_key(self, session_file):
|
|
149
|
+
"""Remove expired temporary API key from session file"""
|
|
150
|
+
try:
|
|
151
|
+
import json
|
|
152
|
+
with open(session_file, 'r') as f:
|
|
153
|
+
session_data = json.load(f)
|
|
154
|
+
|
|
155
|
+
# Remove temp key fields
|
|
156
|
+
session_data.pop('temp_api_key', None)
|
|
157
|
+
session_data.pop('temp_key_expires', None)
|
|
158
|
+
session_data.pop('temp_key_provider', None)
|
|
159
|
+
|
|
160
|
+
# Write back
|
|
161
|
+
with open(session_file, 'w') as f:
|
|
162
|
+
json.dump(session_data, f, indent=2)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.warning(f"Failed to remove expired temp key: {e}")
|
|
165
|
+
|
|
144
166
|
def _load_authentication(self):
|
|
145
167
|
"""Load authentication from session file"""
|
|
146
168
|
use_local_keys = os.getenv("USE_LOCAL_KEYS", "false").lower() == "true"
|
|
@@ -162,6 +184,38 @@ class EnhancedNocturnalAgent:
|
|
|
162
184
|
session_data = json.load(f)
|
|
163
185
|
self.auth_token = session_data.get('auth_token')
|
|
164
186
|
self.user_id = session_data.get('account_id')
|
|
187
|
+
|
|
188
|
+
# NEW: Check for temporary local API key with expiration
|
|
189
|
+
temp_key = session_data.get('temp_api_key')
|
|
190
|
+
temp_key_expires = session_data.get('temp_key_expires')
|
|
191
|
+
|
|
192
|
+
if temp_key and temp_key_expires:
|
|
193
|
+
# Check if key is still valid
|
|
194
|
+
from datetime import datetime, timezone
|
|
195
|
+
try:
|
|
196
|
+
expires_at = datetime.fromisoformat(temp_key_expires.replace('Z', '+00:00'))
|
|
197
|
+
now = datetime.now(timezone.utc)
|
|
198
|
+
|
|
199
|
+
if now < expires_at:
|
|
200
|
+
# Key is still valid - use local mode for speed!
|
|
201
|
+
self.temp_api_key = temp_key
|
|
202
|
+
self.temp_key_provider = session_data.get('temp_key_provider', 'cerebras')
|
|
203
|
+
if debug_mode:
|
|
204
|
+
time_left = (expires_at - now).total_seconds() / 3600
|
|
205
|
+
print(f"✅ Using temporary local key (expires in {time_left:.1f}h)")
|
|
206
|
+
else:
|
|
207
|
+
# Key expired - remove it and fall back to backend
|
|
208
|
+
if debug_mode:
|
|
209
|
+
print(f"⏰ Temporary key expired, using backend mode")
|
|
210
|
+
self._remove_expired_temp_key(session_file)
|
|
211
|
+
self.temp_api_key = None
|
|
212
|
+
except Exception as e:
|
|
213
|
+
if debug_mode:
|
|
214
|
+
print(f"⚠️ Error parsing temp key expiration: {e}")
|
|
215
|
+
self.temp_api_key = None
|
|
216
|
+
else:
|
|
217
|
+
self.temp_api_key = None
|
|
218
|
+
|
|
165
219
|
if debug_mode:
|
|
166
220
|
print(f"🔍 _load_authentication: loaded auth_token={self.auth_token}, user_id={self.user_id}")
|
|
167
221
|
except Exception as e:
|
|
@@ -169,6 +223,7 @@ class EnhancedNocturnalAgent:
|
|
|
169
223
|
print(f"🔍 _load_authentication: ERROR loading session: {e}")
|
|
170
224
|
self.auth_token = None
|
|
171
225
|
self.user_id = None
|
|
226
|
+
self.temp_api_key = None
|
|
172
227
|
else:
|
|
173
228
|
# FALLBACK: Check if config.env has credentials but session.json is missing
|
|
174
229
|
# This handles cases where old setup didn't create session.json
|
|
@@ -917,6 +972,56 @@ class EnhancedNocturnalAgent:
|
|
|
917
972
|
if not api_results:
|
|
918
973
|
logger.info("🔍 DEBUG: _format_api_results_for_prompt called with EMPTY api_results")
|
|
919
974
|
return "No API results yet."
|
|
975
|
+
|
|
976
|
+
# Special formatting for shell results to make them VERY clear
|
|
977
|
+
if "shell_info" in api_results:
|
|
978
|
+
shell_info = api_results["shell_info"]
|
|
979
|
+
formatted_parts = ["=" * 60]
|
|
980
|
+
formatted_parts.append("🔧 SHELL COMMAND EXECUTION RESULTS (ALREADY EXECUTED)")
|
|
981
|
+
formatted_parts.append("=" * 60)
|
|
982
|
+
|
|
983
|
+
if "command" in shell_info:
|
|
984
|
+
formatted_parts.append(f"\n📝 Command that was executed:")
|
|
985
|
+
formatted_parts.append(f" $ {shell_info['command']}")
|
|
986
|
+
|
|
987
|
+
if "output" in shell_info:
|
|
988
|
+
formatted_parts.append(f"\n📤 Command output (THIS IS THE RESULT):")
|
|
989
|
+
formatted_parts.append(f"{shell_info['output']}")
|
|
990
|
+
|
|
991
|
+
if "error" in shell_info:
|
|
992
|
+
formatted_parts.append(f"\n❌ Error occurred:")
|
|
993
|
+
formatted_parts.append(f"{shell_info['error']}")
|
|
994
|
+
|
|
995
|
+
if "directory_contents" in shell_info:
|
|
996
|
+
formatted_parts.append(f"\n📂 Directory listing (THIS IS THE RESULT):")
|
|
997
|
+
formatted_parts.append(f"{shell_info['directory_contents']}")
|
|
998
|
+
|
|
999
|
+
if "search_results" in shell_info:
|
|
1000
|
+
formatted_parts.append(f"\n🔍 Search results (THIS IS THE RESULT):")
|
|
1001
|
+
formatted_parts.append(f"{shell_info['search_results']}")
|
|
1002
|
+
|
|
1003
|
+
formatted_parts.append("\n" + "=" * 60)
|
|
1004
|
+
formatted_parts.append("🚨 CRITICAL INSTRUCTION 🚨")
|
|
1005
|
+
formatted_parts.append("The command was ALREADY executed. The output above is the COMPLETE and ONLY result.")
|
|
1006
|
+
formatted_parts.append("YOU MUST present ONLY what is shown in the output above.")
|
|
1007
|
+
formatted_parts.append("DO NOT add file names, paths, or code that are NOT in the output above.")
|
|
1008
|
+
formatted_parts.append("DO NOT make up examples or additional results.")
|
|
1009
|
+
formatted_parts.append("If the output says 'No matches' or is empty, tell the user 'No results found'.")
|
|
1010
|
+
formatted_parts.append("DO NOT ask the user to run any commands - the results are already here.")
|
|
1011
|
+
formatted_parts.append("=" * 60)
|
|
1012
|
+
|
|
1013
|
+
# Add other api_results
|
|
1014
|
+
other_results = {k: v for k, v in api_results.items() if k != "shell_info"}
|
|
1015
|
+
if other_results:
|
|
1016
|
+
try:
|
|
1017
|
+
serialized = json.dumps(other_results, indent=2)
|
|
1018
|
+
except Exception:
|
|
1019
|
+
serialized = str(other_results)
|
|
1020
|
+
formatted_parts.append(f"\nOther data:\n{serialized}")
|
|
1021
|
+
|
|
1022
|
+
return "\n".join(formatted_parts)
|
|
1023
|
+
|
|
1024
|
+
# Normal formatting for non-shell results
|
|
920
1025
|
try:
|
|
921
1026
|
serialized = json.dumps(api_results, indent=2)
|
|
922
1027
|
except Exception:
|
|
@@ -970,15 +1075,24 @@ class EnhancedNocturnalAgent:
|
|
|
970
1075
|
"PRIMARY DIRECTIVE: Execute code when needed. You have a persistent shell session. "
|
|
971
1076
|
"When user asks for data analysis, calculations, or file operations: WRITE and EXECUTE the code. "
|
|
972
1077
|
"Languages available: Python, R, SQL, Bash. "
|
|
973
|
-
"
|
|
1078
|
+
"🚨 CRITICAL: Commands are AUTOMATICALLY executed. If you see 'shell_info' below, "
|
|
1079
|
+
"that means the command was ALREADY RUN. NEVER ask users to run commands - just present results."
|
|
974
1080
|
)
|
|
975
1081
|
else:
|
|
976
1082
|
intro = (
|
|
977
1083
|
"You are Cite Agent, a truth-seeking research and finance AI with CODE EXECUTION. "
|
|
978
|
-
"PRIMARY DIRECTIVE: Accuracy > Agreeableness.
|
|
1084
|
+
"PRIMARY DIRECTIVE: Accuracy > Agreeableness. NEVER HALLUCINATE. "
|
|
979
1085
|
"You are a fact-checker and analyst with a persistent shell session. "
|
|
980
1086
|
"You have access to research (Archive), financial data (FinSight SEC filings), and can run Python/R/SQL/Bash. "
|
|
981
|
-
"
|
|
1087
|
+
"\n\n"
|
|
1088
|
+
"🚨 ANTI-HALLUCINATION RULES:\n"
|
|
1089
|
+
"1. When user asks about files, directories, or data - commands are AUTOMATICALLY executed.\n"
|
|
1090
|
+
"2. If you see 'shell_info' in results below, that means command was ALREADY RUN.\n"
|
|
1091
|
+
"3. ONLY present information from shell_info output. DO NOT invent file names, paths, or code.\n"
|
|
1092
|
+
"4. If shell output is empty or unclear, say 'No results found' or 'Search returned no matches'.\n"
|
|
1093
|
+
"5. NEVER make up plausible-sounding file paths or code that wasn't in the actual output.\n"
|
|
1094
|
+
"6. If you're unsure, say 'I couldn't find that' rather than guessing.\n"
|
|
1095
|
+
"7. NEVER ask the user to run commands - just present the results that were already executed."
|
|
982
1096
|
)
|
|
983
1097
|
|
|
984
1098
|
sections.append(intro)
|
|
@@ -1003,7 +1117,17 @@ class EnhancedNocturnalAgent:
|
|
|
1003
1117
|
capability_lines.append("• You can SEARCH user's paper collection")
|
|
1004
1118
|
capability_lines.append("• You can COPY text to user's clipboard")
|
|
1005
1119
|
capability_lines.append("• User's query history is automatically tracked")
|
|
1006
|
-
|
|
1120
|
+
|
|
1121
|
+
# Add file operation capabilities (Claude Code / Cursor parity)
|
|
1122
|
+
capability_lines.append("")
|
|
1123
|
+
capability_lines.append("📁 DIRECT FILE OPERATIONS (Always available):")
|
|
1124
|
+
capability_lines.append("• read_file(path) - Read files with line numbers (like cat but better)")
|
|
1125
|
+
capability_lines.append("• write_file(path, content) - Create/overwrite files directly")
|
|
1126
|
+
capability_lines.append("• edit_file(path, old, new) - Surgical find/replace edits")
|
|
1127
|
+
capability_lines.append("• glob_search(pattern) - Fast file search (e.g., '**/*.py')")
|
|
1128
|
+
capability_lines.append("• grep_search(pattern) - Fast content search in files")
|
|
1129
|
+
capability_lines.append("• batch_edit_files(edits) - Multi-file refactoring")
|
|
1130
|
+
|
|
1007
1131
|
sections.append("Capabilities in play:\n" + "\n".join(capability_lines))
|
|
1008
1132
|
|
|
1009
1133
|
# ENHANCED TRUTH-SEEKING RULES (adapt based on mode)
|
|
@@ -1098,6 +1222,48 @@ class EnhancedNocturnalAgent:
|
|
|
1098
1222
|
"• Example: 'I found 3 papers. I can save them to your library or export to BibTeX if you'd like.'",
|
|
1099
1223
|
]
|
|
1100
1224
|
rules.extend(workflow_rules)
|
|
1225
|
+
|
|
1226
|
+
# Add file operation tool usage rules (CRITICAL for Claude Code parity)
|
|
1227
|
+
file_ops_rules = [
|
|
1228
|
+
"",
|
|
1229
|
+
"📁 FILE OPERATION TOOL USAGE (Use these INSTEAD of shell commands):",
|
|
1230
|
+
"",
|
|
1231
|
+
"🔴 ALWAYS PREFER (in order):",
|
|
1232
|
+
"1. read_file(path) → INSTEAD OF: cat, head, tail",
|
|
1233
|
+
"2. write_file(path, content) → INSTEAD OF: echo >, cat << EOF, printf >",
|
|
1234
|
+
"3. edit_file(path, old, new) → INSTEAD OF: sed, awk",
|
|
1235
|
+
"4. glob_search(pattern, path) → INSTEAD OF: find, ls",
|
|
1236
|
+
"5. grep_search(pattern, path, file_pattern) → INSTEAD OF: grep -r",
|
|
1237
|
+
"",
|
|
1238
|
+
"✅ CORRECT USAGE:",
|
|
1239
|
+
"• Reading code: result = read_file('app.py')",
|
|
1240
|
+
"• Creating file: write_file('config.json', '{...}')",
|
|
1241
|
+
"• Editing code: edit_file('main.py', 'old_var', 'new_var', replace_all=True)",
|
|
1242
|
+
"• Finding files: glob_search('**/*.py', '/home/user/project')",
|
|
1243
|
+
"• Searching code: grep_search('class.*Agent', '.', '*.py', output_mode='content')",
|
|
1244
|
+
"• Multi-file refactor: batch_edit_files([{file: 'a.py', old: '...', new: '...'}, ...])",
|
|
1245
|
+
"",
|
|
1246
|
+
"❌ ANTI-PATTERNS (Don't do these):",
|
|
1247
|
+
"• DON'T use cat when read_file exists",
|
|
1248
|
+
"• DON'T use echo > when write_file exists",
|
|
1249
|
+
"• DON'T use sed when edit_file exists",
|
|
1250
|
+
"• DON'T use find when glob_search exists",
|
|
1251
|
+
"• DON'T use grep -r when grep_search exists",
|
|
1252
|
+
"",
|
|
1253
|
+
"🎯 WHY USE THESE TOOLS:",
|
|
1254
|
+
"• read_file() shows line numbers (critical for code analysis)",
|
|
1255
|
+
"• write_file() handles escaping/quoting automatically (no heredoc hell)",
|
|
1256
|
+
"• edit_file() validates changes before applying (safer than sed)",
|
|
1257
|
+
"• glob_search() is faster and cleaner than find",
|
|
1258
|
+
"• grep_search() returns structured data (easier to parse)",
|
|
1259
|
+
"",
|
|
1260
|
+
"⚠️ SHELL COMMANDS ONLY FOR:",
|
|
1261
|
+
"• System operations (ps, df, du, uptime)",
|
|
1262
|
+
"• Git commands (git status, git diff, git log)",
|
|
1263
|
+
"• Package installs (pip install, Rscript -e \"install.packages(...)\")",
|
|
1264
|
+
"• Running Python/R scripts (python script.py, Rscript analysis.R)",
|
|
1265
|
+
]
|
|
1266
|
+
rules.extend(file_ops_rules)
|
|
1101
1267
|
|
|
1102
1268
|
sections.append("CRITICAL RULES:\n" + "\n".join(rules))
|
|
1103
1269
|
|
|
@@ -1227,7 +1393,7 @@ class EnhancedNocturnalAgent:
|
|
|
1227
1393
|
"temperature": 0.2
|
|
1228
1394
|
}
|
|
1229
1395
|
return {
|
|
1230
|
-
"model": "
|
|
1396
|
+
"model": "gpt-oss-120b", # PRODUCTION: Cerebras gpt-oss-120b - 100% test pass, 60K TPM
|
|
1231
1397
|
"max_tokens": 900,
|
|
1232
1398
|
"temperature": 0.3
|
|
1233
1399
|
}
|
|
@@ -1240,7 +1406,7 @@ class EnhancedNocturnalAgent:
|
|
|
1240
1406
|
"temperature": 0.2
|
|
1241
1407
|
}
|
|
1242
1408
|
return {
|
|
1243
|
-
"model": "
|
|
1409
|
+
"model": "openai/gpt-oss-120b", # PRODUCTION: 120B model - 100% test pass rate
|
|
1244
1410
|
"max_tokens": 900,
|
|
1245
1411
|
"temperature": 0.3
|
|
1246
1412
|
}
|
|
@@ -1462,6 +1628,49 @@ class EnhancedNocturnalAgent:
|
|
|
1462
1628
|
seen.add(t)
|
|
1463
1629
|
ordered.append(t)
|
|
1464
1630
|
return ordered[:4]
|
|
1631
|
+
|
|
1632
|
+
def _plan_financial_request(self, question: str, session_key: Optional[str] = None) -> Tuple[List[str], List[str]]:
|
|
1633
|
+
"""Derive ticker and metric targets for a financial query."""
|
|
1634
|
+
tickers = list(self._extract_tickers_from_text(question))
|
|
1635
|
+
question_lower = question.lower()
|
|
1636
|
+
|
|
1637
|
+
if not tickers:
|
|
1638
|
+
if "apple" in question_lower:
|
|
1639
|
+
tickers.append("AAPL")
|
|
1640
|
+
if "microsoft" in question_lower:
|
|
1641
|
+
tickers.append("MSFT" if "AAPL" not in tickers else "MSFT")
|
|
1642
|
+
|
|
1643
|
+
metrics_to_fetch: List[str] = []
|
|
1644
|
+
keyword_map = [
|
|
1645
|
+
("revenue", ["revenue", "sales", "top line"]),
|
|
1646
|
+
("grossProfit", ["gross profit", "gross margin", "margin"]),
|
|
1647
|
+
("operatingIncome", ["operating income", "operating profit", "ebit"]),
|
|
1648
|
+
("netIncome", ["net income", "profit", "earnings", "bottom line"]),
|
|
1649
|
+
]
|
|
1650
|
+
|
|
1651
|
+
for metric, keywords in keyword_map:
|
|
1652
|
+
if any(kw in question_lower for kw in keywords):
|
|
1653
|
+
metrics_to_fetch.append(metric)
|
|
1654
|
+
|
|
1655
|
+
if session_key:
|
|
1656
|
+
last_topic = self._session_topics.get(session_key)
|
|
1657
|
+
else:
|
|
1658
|
+
last_topic = None
|
|
1659
|
+
|
|
1660
|
+
if not metrics_to_fetch and last_topic and last_topic.get("metrics"):
|
|
1661
|
+
metrics_to_fetch = list(last_topic["metrics"])
|
|
1662
|
+
|
|
1663
|
+
if not metrics_to_fetch:
|
|
1664
|
+
metrics_to_fetch = ["revenue", "grossProfit"]
|
|
1665
|
+
|
|
1666
|
+
deduped: List[str] = []
|
|
1667
|
+
seen: Set[str] = set()
|
|
1668
|
+
for symbol in tickers:
|
|
1669
|
+
if symbol and symbol not in seen:
|
|
1670
|
+
seen.add(symbol)
|
|
1671
|
+
deduped.append(symbol)
|
|
1672
|
+
|
|
1673
|
+
return deduped[:2], metrics_to_fetch
|
|
1465
1674
|
|
|
1466
1675
|
async def initialize(self, force_reload: bool = False):
|
|
1467
1676
|
"""Initialize the agent with API keys and shell session."""
|
|
@@ -1496,8 +1705,10 @@ class EnhancedNocturnalAgent:
|
|
|
1496
1705
|
use_local_keys_env = os.getenv("USE_LOCAL_KEYS", "").lower()
|
|
1497
1706
|
|
|
1498
1707
|
if has_session:
|
|
1499
|
-
# Session exists →
|
|
1500
|
-
|
|
1708
|
+
# Session exists → Check if we have temp local key for speed
|
|
1709
|
+
# If temp key exists and valid → use local mode (fast!)
|
|
1710
|
+
# Otherwise → use backend mode (secure but slow)
|
|
1711
|
+
use_local_keys = hasattr(self, 'temp_api_key') and self.temp_api_key is not None
|
|
1501
1712
|
elif use_local_keys_env == "true":
|
|
1502
1713
|
# No session but dev mode requested → use local keys
|
|
1503
1714
|
use_local_keys = True
|
|
@@ -1545,16 +1756,24 @@ class EnhancedNocturnalAgent:
|
|
|
1545
1756
|
else:
|
|
1546
1757
|
print("⚠️ Not authenticated. Please log in to use the agent.")
|
|
1547
1758
|
else:
|
|
1548
|
-
# Local keys mode -
|
|
1549
|
-
self.auth_token = None
|
|
1550
|
-
self.user_id = None
|
|
1759
|
+
# Local keys mode - use temporary key if available, otherwise load from env
|
|
1551
1760
|
|
|
1552
|
-
#
|
|
1553
|
-
self
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1761
|
+
# Check if we have a temporary key (for speed + security)
|
|
1762
|
+
if hasattr(self, 'temp_api_key') and self.temp_api_key:
|
|
1763
|
+
# Use temporary key provided by backend
|
|
1764
|
+
self.api_keys = [self.temp_api_key]
|
|
1765
|
+
self.llm_provider = getattr(self, 'temp_key_provider', 'cerebras')
|
|
1766
|
+
else:
|
|
1767
|
+
# Fallback: Load permanent keys from environment (dev mode only)
|
|
1768
|
+
self.auth_token = None
|
|
1769
|
+
self.user_id = None
|
|
1770
|
+
|
|
1771
|
+
# Load Cerebras keys from environment (PRIMARY)
|
|
1772
|
+
self.api_keys = []
|
|
1773
|
+
for i in range(1, 10): # Check CEREBRAS_API_KEY_1 through CEREBRAS_API_KEY_9
|
|
1774
|
+
key = os.getenv(f"CEREBRAS_API_KEY_{i}") or os.getenv(f"CEREBRAS_API_KEY")
|
|
1775
|
+
if key and key not in self.api_keys:
|
|
1776
|
+
self.api_keys.append(key)
|
|
1558
1777
|
|
|
1559
1778
|
# Fallback to Groq keys if no Cerebras keys found
|
|
1560
1779
|
if not self.api_keys:
|
|
@@ -1598,8 +1817,12 @@ class EnhancedNocturnalAgent:
|
|
|
1598
1817
|
|
|
1599
1818
|
if self.shell_session is None:
|
|
1600
1819
|
try:
|
|
1820
|
+
if self._is_windows:
|
|
1821
|
+
command = ['powershell', '-NoLogo', '-NoProfile']
|
|
1822
|
+
else:
|
|
1823
|
+
command = ['bash']
|
|
1601
1824
|
self.shell_session = subprocess.Popen(
|
|
1602
|
-
|
|
1825
|
+
command,
|
|
1603
1826
|
stdin=subprocess.PIPE,
|
|
1604
1827
|
stdout=subprocess.PIPE,
|
|
1605
1828
|
stderr=subprocess.STDOUT,
|
|
@@ -1674,7 +1897,7 @@ class EnhancedNocturnalAgent:
|
|
|
1674
1897
|
"query": query, # Keep query clean
|
|
1675
1898
|
"conversation_history": conversation_history or [],
|
|
1676
1899
|
"api_context": api_results, # Send API results separately
|
|
1677
|
-
"model": "
|
|
1900
|
+
"model": "openai/gpt-oss-120b", # PRODUCTION: 120B - best test results
|
|
1678
1901
|
"temperature": 0.2, # Low temp for accuracy
|
|
1679
1902
|
"max_tokens": 4000
|
|
1680
1903
|
}
|
|
@@ -1744,7 +1967,7 @@ class EnhancedNocturnalAgent:
|
|
|
1744
1967
|
response=response_text,
|
|
1745
1968
|
tokens_used=tokens,
|
|
1746
1969
|
tools_used=all_tools,
|
|
1747
|
-
model=data.get('model', '
|
|
1970
|
+
model=data.get('model', 'openai/gpt-oss-120b'),
|
|
1748
1971
|
timestamp=data.get('timestamp', datetime.now(timezone.utc).isoformat()),
|
|
1749
1972
|
api_results=api_results
|
|
1750
1973
|
)
|
|
@@ -1783,7 +2006,7 @@ class EnhancedNocturnalAgent:
|
|
|
1783
2006
|
response=response_text,
|
|
1784
2007
|
tokens_used=tokens,
|
|
1785
2008
|
tools_used=all_tools,
|
|
1786
|
-
model=data.get('model', '
|
|
2009
|
+
model=data.get('model', 'openai/gpt-oss-120b'),
|
|
1787
2010
|
timestamp=data.get('timestamp', datetime.now(timezone.utc).isoformat()),
|
|
1788
2011
|
api_results=api_results
|
|
1789
2012
|
)
|
|
@@ -2134,7 +2357,30 @@ class EnhancedNocturnalAgent:
|
|
|
2134
2357
|
results.update(payload)
|
|
2135
2358
|
|
|
2136
2359
|
return results
|
|
2137
|
-
|
|
2360
|
+
|
|
2361
|
+
def _looks_like_user_prompt(self, command: str) -> bool:
|
|
2362
|
+
command_lower = command.strip().lower()
|
|
2363
|
+
if not command_lower:
|
|
2364
|
+
return True
|
|
2365
|
+
phrases = [
|
|
2366
|
+
"ask the user",
|
|
2367
|
+
"can you run",
|
|
2368
|
+
"please run",
|
|
2369
|
+
"tell the user",
|
|
2370
|
+
"ask them",
|
|
2371
|
+
]
|
|
2372
|
+
return any(phrase in command_lower for phrase in phrases)
|
|
2373
|
+
|
|
2374
|
+
def _infer_shell_command(self, question: str) -> str:
|
|
2375
|
+
question_lower = question.lower()
|
|
2376
|
+
if any(word in question_lower for word in ["list", "show", "files", "directory", "folder", "ls"]):
|
|
2377
|
+
return "ls -lah"
|
|
2378
|
+
if any(word in question_lower for word in ["where", "pwd", "current directory", "location"]):
|
|
2379
|
+
return "pwd"
|
|
2380
|
+
if "read" in question_lower and any(ext in question_lower for ext in [".py", ".txt", ".csv", "file"]):
|
|
2381
|
+
return "ls -lah"
|
|
2382
|
+
return "pwd"
|
|
2383
|
+
|
|
2138
2384
|
def execute_command(self, command: str) -> str:
|
|
2139
2385
|
"""Execute command and return output - improved with echo markers"""
|
|
2140
2386
|
try:
|
|
@@ -2164,10 +2410,14 @@ class EnhancedNocturnalAgent:
|
|
|
2164
2410
|
marker = f"CMD_DONE_{uuid.uuid4().hex[:8]}"
|
|
2165
2411
|
|
|
2166
2412
|
# Send command with marker
|
|
2167
|
-
|
|
2413
|
+
terminator = "\r\n" if self._is_windows else "\n"
|
|
2414
|
+
if self._is_windows:
|
|
2415
|
+
full_command = f"{command}; echo '{marker}'{terminator}"
|
|
2416
|
+
else:
|
|
2417
|
+
full_command = f"{command}; echo '{marker}'{terminator}"
|
|
2168
2418
|
self.shell_session.stdin.write(full_command)
|
|
2169
2419
|
self.shell_session.stdin.flush()
|
|
2170
|
-
|
|
2420
|
+
|
|
2171
2421
|
# Read until we see the marker
|
|
2172
2422
|
output_lines = []
|
|
2173
2423
|
start_time = time.time()
|
|
@@ -2191,10 +2441,478 @@ class EnhancedNocturnalAgent:
|
|
|
2191
2441
|
|
|
2192
2442
|
output = '\n'.join(output_lines).strip()
|
|
2193
2443
|
return output if output else "Command executed (no output)"
|
|
2194
|
-
|
|
2444
|
+
|
|
2195
2445
|
except Exception as e:
|
|
2196
2446
|
return f"ERROR: {e}"
|
|
2197
2447
|
|
|
2448
|
+
# ========================================================================
|
|
2449
|
+
# DIRECT FILE OPERATIONS (Claude Code / Cursor Parity)
|
|
2450
|
+
# ========================================================================
|
|
2451
|
+
|
|
2452
|
+
def read_file(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
|
|
2453
|
+
"""
|
|
2454
|
+
Read file with line numbers (like Claude Code's Read tool)
|
|
2455
|
+
|
|
2456
|
+
Args:
|
|
2457
|
+
file_path: Path to file
|
|
2458
|
+
offset: Starting line number (0-indexed)
|
|
2459
|
+
limit: Maximum number of lines to read
|
|
2460
|
+
|
|
2461
|
+
Returns:
|
|
2462
|
+
File contents with line numbers in format: " 123→content"
|
|
2463
|
+
"""
|
|
2464
|
+
try:
|
|
2465
|
+
# Expand ~ to home directory
|
|
2466
|
+
file_path = os.path.expanduser(file_path)
|
|
2467
|
+
|
|
2468
|
+
# Make absolute if relative
|
|
2469
|
+
if not os.path.isabs(file_path):
|
|
2470
|
+
file_path = os.path.abspath(file_path)
|
|
2471
|
+
|
|
2472
|
+
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
2473
|
+
lines = f.readlines()
|
|
2474
|
+
|
|
2475
|
+
# Apply offset and limit
|
|
2476
|
+
if offset or limit:
|
|
2477
|
+
lines = lines[offset:offset+limit if limit else None]
|
|
2478
|
+
|
|
2479
|
+
# Format with line numbers (1-indexed, like vim/editors)
|
|
2480
|
+
numbered_lines = [
|
|
2481
|
+
f"{offset+i+1:6d}→{line.rstrip()}\n"
|
|
2482
|
+
for i, line in enumerate(lines)
|
|
2483
|
+
]
|
|
2484
|
+
|
|
2485
|
+
result = ''.join(numbered_lines)
|
|
2486
|
+
|
|
2487
|
+
# Update file context
|
|
2488
|
+
self.file_context['last_file'] = file_path
|
|
2489
|
+
if file_path not in self.file_context['recent_files']:
|
|
2490
|
+
self.file_context['recent_files'].append(file_path)
|
|
2491
|
+
self.file_context['recent_files'] = self.file_context['recent_files'][-5:]
|
|
2492
|
+
|
|
2493
|
+
return result if result else "(empty file)"
|
|
2494
|
+
|
|
2495
|
+
except FileNotFoundError:
|
|
2496
|
+
return f"ERROR: File not found: {file_path}"
|
|
2497
|
+
except PermissionError:
|
|
2498
|
+
return f"ERROR: Permission denied: {file_path}"
|
|
2499
|
+
except IsADirectoryError:
|
|
2500
|
+
return f"ERROR: {file_path} is a directory, not a file"
|
|
2501
|
+
except Exception as e:
|
|
2502
|
+
return f"ERROR: {type(e).__name__}: {e}"
|
|
2503
|
+
|
|
2504
|
+
def write_file(self, file_path: str, content: str) -> Dict[str, Any]:
|
|
2505
|
+
"""
|
|
2506
|
+
Write file directly (like Claude Code's Write tool)
|
|
2507
|
+
Creates new file or overwrites existing one.
|
|
2508
|
+
|
|
2509
|
+
Args:
|
|
2510
|
+
file_path: Path to file
|
|
2511
|
+
content: Full file content
|
|
2512
|
+
|
|
2513
|
+
Returns:
|
|
2514
|
+
{"success": bool, "message": str, "bytes_written": int}
|
|
2515
|
+
"""
|
|
2516
|
+
try:
|
|
2517
|
+
# Expand ~ to home directory
|
|
2518
|
+
file_path = os.path.expanduser(file_path)
|
|
2519
|
+
|
|
2520
|
+
# Make absolute if relative
|
|
2521
|
+
if not os.path.isabs(file_path):
|
|
2522
|
+
file_path = os.path.abspath(file_path)
|
|
2523
|
+
|
|
2524
|
+
# Create parent directories if needed
|
|
2525
|
+
parent_dir = os.path.dirname(file_path)
|
|
2526
|
+
if parent_dir and not os.path.exists(parent_dir):
|
|
2527
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
2528
|
+
|
|
2529
|
+
# Write file
|
|
2530
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
2531
|
+
bytes_written = f.write(content)
|
|
2532
|
+
|
|
2533
|
+
# Update file context
|
|
2534
|
+
self.file_context['last_file'] = file_path
|
|
2535
|
+
if file_path not in self.file_context['recent_files']:
|
|
2536
|
+
self.file_context['recent_files'].append(file_path)
|
|
2537
|
+
self.file_context['recent_files'] = self.file_context['recent_files'][-5:]
|
|
2538
|
+
|
|
2539
|
+
return {
|
|
2540
|
+
"success": True,
|
|
2541
|
+
"message": f"Wrote {bytes_written} bytes to {file_path}",
|
|
2542
|
+
"bytes_written": bytes_written
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
except PermissionError:
|
|
2546
|
+
return {
|
|
2547
|
+
"success": False,
|
|
2548
|
+
"message": f"ERROR: Permission denied: {file_path}",
|
|
2549
|
+
"bytes_written": 0
|
|
2550
|
+
}
|
|
2551
|
+
except Exception as e:
|
|
2552
|
+
return {
|
|
2553
|
+
"success": False,
|
|
2554
|
+
"message": f"ERROR: {type(e).__name__}: {e}",
|
|
2555
|
+
"bytes_written": 0
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
def edit_file(self, file_path: str, old_string: str, new_string: str,
|
|
2559
|
+
replace_all: bool = False) -> Dict[str, Any]:
|
|
2560
|
+
"""
|
|
2561
|
+
Surgical file edit (like Claude Code's Edit tool)
|
|
2562
|
+
|
|
2563
|
+
Args:
|
|
2564
|
+
file_path: Path to file
|
|
2565
|
+
old_string: Exact string to replace (must be unique unless replace_all=True)
|
|
2566
|
+
new_string: Replacement string
|
|
2567
|
+
replace_all: If True, replace all occurrences. If False, old_string must be unique.
|
|
2568
|
+
|
|
2569
|
+
Returns:
|
|
2570
|
+
{"success": bool, "message": str, "replacements": int}
|
|
2571
|
+
"""
|
|
2572
|
+
try:
|
|
2573
|
+
# Expand ~ to home directory
|
|
2574
|
+
file_path = os.path.expanduser(file_path)
|
|
2575
|
+
|
|
2576
|
+
# Make absolute if relative
|
|
2577
|
+
if not os.path.isabs(file_path):
|
|
2578
|
+
file_path = os.path.abspath(file_path)
|
|
2579
|
+
|
|
2580
|
+
# Read file
|
|
2581
|
+
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
2582
|
+
content = f.read()
|
|
2583
|
+
|
|
2584
|
+
# Check if old_string exists
|
|
2585
|
+
if old_string not in content:
|
|
2586
|
+
return {
|
|
2587
|
+
"success": False,
|
|
2588
|
+
"message": f"ERROR: old_string not found in {file_path}",
|
|
2589
|
+
"replacements": 0
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
# Check uniqueness if not replace_all
|
|
2593
|
+
occurrences = content.count(old_string)
|
|
2594
|
+
if not replace_all and occurrences > 1:
|
|
2595
|
+
return {
|
|
2596
|
+
"success": False,
|
|
2597
|
+
"message": f"ERROR: old_string appears {occurrences} times in {file_path}. Use replace_all=True or provide more context to make it unique.",
|
|
2598
|
+
"replacements": 0
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
# Perform replacement
|
|
2602
|
+
if replace_all:
|
|
2603
|
+
new_content = content.replace(old_string, new_string)
|
|
2604
|
+
else:
|
|
2605
|
+
new_content = content.replace(old_string, new_string, 1)
|
|
2606
|
+
|
|
2607
|
+
# Write back
|
|
2608
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
2609
|
+
f.write(new_content)
|
|
2610
|
+
|
|
2611
|
+
# Update file context
|
|
2612
|
+
self.file_context['last_file'] = file_path
|
|
2613
|
+
|
|
2614
|
+
return {
|
|
2615
|
+
"success": True,
|
|
2616
|
+
"message": f"Replaced {occurrences if replace_all else 1} occurrence(s) in {file_path}",
|
|
2617
|
+
"replacements": occurrences if replace_all else 1
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
except FileNotFoundError:
|
|
2621
|
+
return {
|
|
2622
|
+
"success": False,
|
|
2623
|
+
"message": f"ERROR: File not found: {file_path}",
|
|
2624
|
+
"replacements": 0
|
|
2625
|
+
}
|
|
2626
|
+
except PermissionError:
|
|
2627
|
+
return {
|
|
2628
|
+
"success": False,
|
|
2629
|
+
"message": f"ERROR: Permission denied: {file_path}",
|
|
2630
|
+
"replacements": 0
|
|
2631
|
+
}
|
|
2632
|
+
except Exception as e:
|
|
2633
|
+
return {
|
|
2634
|
+
"success": False,
|
|
2635
|
+
"message": f"ERROR: {type(e).__name__}: {e}",
|
|
2636
|
+
"replacements": 0
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
def glob_search(self, pattern: str, path: str = ".") -> Dict[str, Any]:
|
|
2640
|
+
"""
|
|
2641
|
+
Fast file pattern matching (like Claude Code's Glob tool)
|
|
2642
|
+
|
|
2643
|
+
Args:
|
|
2644
|
+
pattern: Glob pattern (e.g., "*.py", "**/*.md", "src/**/*.ts")
|
|
2645
|
+
path: Starting directory (default: current directory)
|
|
2646
|
+
|
|
2647
|
+
Returns:
|
|
2648
|
+
{"files": List[str], "count": int, "pattern": str}
|
|
2649
|
+
"""
|
|
2650
|
+
try:
|
|
2651
|
+
import glob as glob_module
|
|
2652
|
+
|
|
2653
|
+
# Expand ~ to home directory
|
|
2654
|
+
path = os.path.expanduser(path)
|
|
2655
|
+
|
|
2656
|
+
# Make absolute if relative
|
|
2657
|
+
if not os.path.isabs(path):
|
|
2658
|
+
path = os.path.abspath(path)
|
|
2659
|
+
|
|
2660
|
+
# Combine path and pattern
|
|
2661
|
+
full_pattern = os.path.join(path, pattern)
|
|
2662
|
+
|
|
2663
|
+
# Find matches (recursive if ** in pattern)
|
|
2664
|
+
matches = glob_module.glob(full_pattern, recursive=True)
|
|
2665
|
+
|
|
2666
|
+
# Filter to files only (not directories)
|
|
2667
|
+
files = [f for f in matches if os.path.isfile(f)]
|
|
2668
|
+
|
|
2669
|
+
# Sort by modification time (newest first)
|
|
2670
|
+
files.sort(key=lambda f: os.path.getmtime(f), reverse=True)
|
|
2671
|
+
|
|
2672
|
+
return {
|
|
2673
|
+
"files": files,
|
|
2674
|
+
"count": len(files),
|
|
2675
|
+
"pattern": full_pattern
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
except Exception as e:
|
|
2679
|
+
return {
|
|
2680
|
+
"files": [],
|
|
2681
|
+
"count": 0,
|
|
2682
|
+
"pattern": pattern,
|
|
2683
|
+
"error": f"{type(e).__name__}: {e}"
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
def grep_search(self, pattern: str, path: str = ".",
|
|
2687
|
+
file_pattern: str = "*",
|
|
2688
|
+
output_mode: str = "files_with_matches",
|
|
2689
|
+
context_lines: int = 0,
|
|
2690
|
+
ignore_case: bool = False,
|
|
2691
|
+
max_results: int = 100) -> Dict[str, Any]:
|
|
2692
|
+
"""
|
|
2693
|
+
Fast content search (like Claude Code's Grep tool / ripgrep)
|
|
2694
|
+
|
|
2695
|
+
Args:
|
|
2696
|
+
pattern: Regex pattern to search for
|
|
2697
|
+
path: Directory to search in
|
|
2698
|
+
file_pattern: Glob pattern for files to search (e.g., "*.py")
|
|
2699
|
+
output_mode: "files_with_matches", "content", or "count"
|
|
2700
|
+
context_lines: Lines of context around matches
|
|
2701
|
+
ignore_case: Case-insensitive search
|
|
2702
|
+
max_results: Maximum number of results to return
|
|
2703
|
+
|
|
2704
|
+
Returns:
|
|
2705
|
+
Depends on output_mode:
|
|
2706
|
+
- files_with_matches: {"files": List[str], "count": int}
|
|
2707
|
+
- content: {"matches": {file: [(line_num, line_content), ...]}}
|
|
2708
|
+
- count: {"counts": {file: match_count}}
|
|
2709
|
+
"""
|
|
2710
|
+
try:
|
|
2711
|
+
# import re removed - using module-level import
|
|
2712
|
+
|
|
2713
|
+
# Expand ~ to home directory
|
|
2714
|
+
path = os.path.expanduser(path)
|
|
2715
|
+
|
|
2716
|
+
# Make absolute if relative
|
|
2717
|
+
if not os.path.isabs(path):
|
|
2718
|
+
path = os.path.abspath(path)
|
|
2719
|
+
|
|
2720
|
+
# Compile regex
|
|
2721
|
+
flags = re.IGNORECASE if ignore_case else 0
|
|
2722
|
+
regex = re.compile(pattern, flags)
|
|
2723
|
+
|
|
2724
|
+
# Find files to search
|
|
2725
|
+
glob_result = self.glob_search(file_pattern, path)
|
|
2726
|
+
files_to_search = glob_result["files"]
|
|
2727
|
+
|
|
2728
|
+
# Search each file
|
|
2729
|
+
if output_mode == "files_with_matches":
|
|
2730
|
+
matching_files = []
|
|
2731
|
+
for file_path in files_to_search[:max_results]:
|
|
2732
|
+
try:
|
|
2733
|
+
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
2734
|
+
content = f.read()
|
|
2735
|
+
if regex.search(content):
|
|
2736
|
+
matching_files.append(file_path)
|
|
2737
|
+
except:
|
|
2738
|
+
continue
|
|
2739
|
+
|
|
2740
|
+
return {
|
|
2741
|
+
"files": matching_files,
|
|
2742
|
+
"count": len(matching_files),
|
|
2743
|
+
"pattern": pattern
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
elif output_mode == "content":
|
|
2747
|
+
matches = {}
|
|
2748
|
+
for file_path in files_to_search:
|
|
2749
|
+
try:
|
|
2750
|
+
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
2751
|
+
lines = f.readlines()
|
|
2752
|
+
|
|
2753
|
+
file_matches = []
|
|
2754
|
+
for line_num, line in enumerate(lines, 1):
|
|
2755
|
+
if regex.search(line):
|
|
2756
|
+
file_matches.append((line_num, line.rstrip()))
|
|
2757
|
+
|
|
2758
|
+
if len(file_matches) >= max_results:
|
|
2759
|
+
break
|
|
2760
|
+
|
|
2761
|
+
if file_matches:
|
|
2762
|
+
matches[file_path] = file_matches
|
|
2763
|
+
except:
|
|
2764
|
+
continue
|
|
2765
|
+
|
|
2766
|
+
return {
|
|
2767
|
+
"matches": matches,
|
|
2768
|
+
"file_count": len(matches),
|
|
2769
|
+
"pattern": pattern
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
elif output_mode == "count":
|
|
2773
|
+
counts = {}
|
|
2774
|
+
for file_path in files_to_search:
|
|
2775
|
+
try:
|
|
2776
|
+
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
2777
|
+
content = f.read()
|
|
2778
|
+
|
|
2779
|
+
match_count = len(regex.findall(content))
|
|
2780
|
+
if match_count > 0:
|
|
2781
|
+
counts[file_path] = match_count
|
|
2782
|
+
except:
|
|
2783
|
+
continue
|
|
2784
|
+
|
|
2785
|
+
return {
|
|
2786
|
+
"counts": counts,
|
|
2787
|
+
"total_matches": sum(counts.values()),
|
|
2788
|
+
"pattern": pattern
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
else:
|
|
2792
|
+
return {
|
|
2793
|
+
"error": f"Invalid output_mode: {output_mode}. Use 'files_with_matches', 'content', or 'count'."
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
except re.error as e:
|
|
2797
|
+
return {
|
|
2798
|
+
"error": f"Invalid regex pattern: {e}"
|
|
2799
|
+
}
|
|
2800
|
+
except Exception as e:
|
|
2801
|
+
return {
|
|
2802
|
+
"error": f"{type(e).__name__}: {e}"
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
async def batch_edit_files(self, edits: List[Dict[str, str]]) -> Dict[str, Any]:
|
|
2806
|
+
"""
|
|
2807
|
+
Apply multiple file edits atomically (all-or-nothing)
|
|
2808
|
+
|
|
2809
|
+
Args:
|
|
2810
|
+
edits: List of edit operations:
|
|
2811
|
+
[
|
|
2812
|
+
{"file": "path.py", "old": "...", "new": "..."},
|
|
2813
|
+
{"file": "other.py", "old": "...", "new": "...", "replace_all": True},
|
|
2814
|
+
...
|
|
2815
|
+
]
|
|
2816
|
+
|
|
2817
|
+
Returns:
|
|
2818
|
+
{
|
|
2819
|
+
"success": bool,
|
|
2820
|
+
"results": {file: {"success": bool, "message": str, "replacements": int}},
|
|
2821
|
+
"total_edits": int,
|
|
2822
|
+
"failed_edits": int
|
|
2823
|
+
}
|
|
2824
|
+
"""
|
|
2825
|
+
try:
|
|
2826
|
+
results = {}
|
|
2827
|
+
|
|
2828
|
+
# Phase 1: Validate all edits
|
|
2829
|
+
for edit in edits:
|
|
2830
|
+
file_path = edit["file"]
|
|
2831
|
+
old_string = edit["old"]
|
|
2832
|
+
replace_all = edit.get("replace_all", False)
|
|
2833
|
+
|
|
2834
|
+
# Expand path
|
|
2835
|
+
file_path = os.path.expanduser(file_path)
|
|
2836
|
+
if not os.path.isabs(file_path):
|
|
2837
|
+
file_path = os.path.abspath(file_path)
|
|
2838
|
+
|
|
2839
|
+
# Check file exists
|
|
2840
|
+
if not os.path.exists(file_path):
|
|
2841
|
+
return {
|
|
2842
|
+
"success": False,
|
|
2843
|
+
"results": {},
|
|
2844
|
+
"total_edits": 0,
|
|
2845
|
+
"failed_edits": len(edits),
|
|
2846
|
+
"error": f"Validation failed: {file_path} not found. No edits applied."
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
# Check old_string exists
|
|
2850
|
+
try:
|
|
2851
|
+
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
2852
|
+
content = f.read()
|
|
2853
|
+
|
|
2854
|
+
if old_string not in content:
|
|
2855
|
+
return {
|
|
2856
|
+
"success": False,
|
|
2857
|
+
"results": {},
|
|
2858
|
+
"total_edits": 0,
|
|
2859
|
+
"failed_edits": len(edits),
|
|
2860
|
+
"error": f"Validation failed: Pattern not found in {file_path}. No edits applied."
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
# Check uniqueness if not replace_all
|
|
2864
|
+
if not replace_all and content.count(old_string) > 1:
|
|
2865
|
+
return {
|
|
2866
|
+
"success": False,
|
|
2867
|
+
"results": {},
|
|
2868
|
+
"total_edits": 0,
|
|
2869
|
+
"failed_edits": len(edits),
|
|
2870
|
+
"error": f"Validation failed: Pattern appears {content.count(old_string)} times in {file_path}. Use replace_all or provide more context. No edits applied."
|
|
2871
|
+
}
|
|
2872
|
+
except Exception as e:
|
|
2873
|
+
return {
|
|
2874
|
+
"success": False,
|
|
2875
|
+
"results": {},
|
|
2876
|
+
"total_edits": 0,
|
|
2877
|
+
"failed_edits": len(edits),
|
|
2878
|
+
"error": f"Validation failed reading {file_path}: {e}. No edits applied."
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
# Phase 2: Apply all edits (validation passed)
|
|
2882
|
+
for edit in edits:
|
|
2883
|
+
file_path = edit["file"]
|
|
2884
|
+
old_string = edit["old"]
|
|
2885
|
+
new_string = edit["new"]
|
|
2886
|
+
replace_all = edit.get("replace_all", False)
|
|
2887
|
+
|
|
2888
|
+
result = self.edit_file(file_path, old_string, new_string, replace_all)
|
|
2889
|
+
results[file_path] = result
|
|
2890
|
+
|
|
2891
|
+
# Count successes/failures
|
|
2892
|
+
successful_edits = sum(1 for r in results.values() if r["success"])
|
|
2893
|
+
failed_edits = len(edits) - successful_edits
|
|
2894
|
+
|
|
2895
|
+
return {
|
|
2896
|
+
"success": failed_edits == 0,
|
|
2897
|
+
"results": results,
|
|
2898
|
+
"total_edits": len(edits),
|
|
2899
|
+
"successful_edits": successful_edits,
|
|
2900
|
+
"failed_edits": failed_edits
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
except Exception as e:
|
|
2904
|
+
return {
|
|
2905
|
+
"success": False,
|
|
2906
|
+
"results": {},
|
|
2907
|
+
"total_edits": 0,
|
|
2908
|
+
"failed_edits": len(edits),
|
|
2909
|
+
"error": f"Batch edit failed: {type(e).__name__}: {e}"
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
# ========================================================================
|
|
2913
|
+
# END DIRECT FILE OPERATIONS
|
|
2914
|
+
# ========================================================================
|
|
2915
|
+
|
|
2198
2916
|
def _classify_command_safety(self, cmd: str) -> str:
|
|
2199
2917
|
"""
|
|
2200
2918
|
Classify command by safety level for smart execution.
|
|
@@ -2261,7 +2979,39 @@ class EnhancedNocturnalAgent:
|
|
|
2261
2979
|
|
|
2262
2980
|
# Default: Treat unknown commands as requiring user awareness
|
|
2263
2981
|
return 'WRITE'
|
|
2264
|
-
|
|
2982
|
+
|
|
2983
|
+
def _format_archive_summary(
|
|
2984
|
+
self,
|
|
2985
|
+
question: str,
|
|
2986
|
+
response: str,
|
|
2987
|
+
api_results: Dict[str, Any],
|
|
2988
|
+
) -> Dict[str, Any]:
|
|
2989
|
+
"""Prepare compact summary payload for the conversation archive."""
|
|
2990
|
+
clean_question = question.strip().replace("\n", " ")
|
|
2991
|
+
summary_text = response.strip().replace("\n", " ")
|
|
2992
|
+
if len(summary_text) > 320:
|
|
2993
|
+
summary_text = summary_text[:317].rstrip() + "..."
|
|
2994
|
+
|
|
2995
|
+
citations: List[str] = []
|
|
2996
|
+
research = api_results.get("research")
|
|
2997
|
+
if isinstance(research, dict):
|
|
2998
|
+
for item in research.get("results", [])[:3]:
|
|
2999
|
+
title = item.get("title") or item.get("paperTitle")
|
|
3000
|
+
if title:
|
|
3001
|
+
citations.append(title)
|
|
3002
|
+
|
|
3003
|
+
financial = api_results.get("financial")
|
|
3004
|
+
if isinstance(financial, dict):
|
|
3005
|
+
tickers = ", ".join(sorted(financial.keys()))
|
|
3006
|
+
if tickers:
|
|
3007
|
+
citations.append(f"Financial data: {tickers}")
|
|
3008
|
+
|
|
3009
|
+
return {
|
|
3010
|
+
"question": clean_question,
|
|
3011
|
+
"summary": summary_text,
|
|
3012
|
+
"citations": citations,
|
|
3013
|
+
}
|
|
3014
|
+
|
|
2265
3015
|
def _is_safe_shell_command(self, cmd: str) -> bool:
|
|
2266
3016
|
"""
|
|
2267
3017
|
Compatibility wrapper for old safety check.
|
|
@@ -2320,6 +3070,71 @@ class EnhancedNocturnalAgent:
|
|
|
2320
3070
|
self.daily_token_usage += tokens
|
|
2321
3071
|
if user_id:
|
|
2322
3072
|
self.user_token_usage[user_id] = self.user_token_usage.get(user_id, 0) + tokens
|
|
3073
|
+
|
|
3074
|
+
def _finalize_interaction(
|
|
3075
|
+
self,
|
|
3076
|
+
request: ChatRequest,
|
|
3077
|
+
response: ChatResponse,
|
|
3078
|
+
tools_used: Optional[List[str]],
|
|
3079
|
+
api_results: Optional[Dict[str, Any]],
|
|
3080
|
+
request_analysis: Optional[Dict[str, Any]],
|
|
3081
|
+
*,
|
|
3082
|
+
log_workflow: bool = True,
|
|
3083
|
+
) -> ChatResponse:
|
|
3084
|
+
"""Common tail logic: history, memory, workflow logging, archive save."""
|
|
3085
|
+
merged_tools: List[str] = []
|
|
3086
|
+
seen: Set[str] = set()
|
|
3087
|
+
for tool in (tools_used or []) + (response.tools_used or []):
|
|
3088
|
+
if tool and tool not in seen:
|
|
3089
|
+
merged_tools.append(tool)
|
|
3090
|
+
seen.add(tool)
|
|
3091
|
+
response.tools_used = merged_tools
|
|
3092
|
+
|
|
3093
|
+
if request_analysis and not response.confidence_score:
|
|
3094
|
+
response.confidence_score = request_analysis.get("confidence", response.confidence_score) or 0.0
|
|
3095
|
+
|
|
3096
|
+
self.conversation_history.append({"role": "user", "content": request.question})
|
|
3097
|
+
self.conversation_history.append({"role": "assistant", "content": response.response})
|
|
3098
|
+
|
|
3099
|
+
self._update_memory(
|
|
3100
|
+
request.user_id,
|
|
3101
|
+
request.conversation_id,
|
|
3102
|
+
f"Q: {request.question[:100]}... A: {response.response[:100]}...",
|
|
3103
|
+
)
|
|
3104
|
+
|
|
3105
|
+
if log_workflow:
|
|
3106
|
+
try:
|
|
3107
|
+
self.workflow.save_query_result(
|
|
3108
|
+
query=request.question,
|
|
3109
|
+
response=response.response,
|
|
3110
|
+
metadata={
|
|
3111
|
+
"tools_used": response.tools_used,
|
|
3112
|
+
"tokens_used": response.tokens_used,
|
|
3113
|
+
"confidence_score": response.confidence_score,
|
|
3114
|
+
},
|
|
3115
|
+
)
|
|
3116
|
+
except Exception:
|
|
3117
|
+
logger.debug("Workflow logging failed", exc_info=True)
|
|
3118
|
+
|
|
3119
|
+
if getattr(self, "archive", None):
|
|
3120
|
+
try:
|
|
3121
|
+
archive_payload = self._format_archive_summary(
|
|
3122
|
+
request.question,
|
|
3123
|
+
response.response,
|
|
3124
|
+
api_results or {},
|
|
3125
|
+
)
|
|
3126
|
+
self.archive.record_entry(
|
|
3127
|
+
request.user_id,
|
|
3128
|
+
request.conversation_id,
|
|
3129
|
+
archive_payload["question"],
|
|
3130
|
+
archive_payload["summary"],
|
|
3131
|
+
response.tools_used,
|
|
3132
|
+
archive_payload["citations"],
|
|
3133
|
+
)
|
|
3134
|
+
except Exception as archive_error:
|
|
3135
|
+
logger.debug("Archive write failed", error=str(archive_error))
|
|
3136
|
+
|
|
3137
|
+
return response
|
|
2323
3138
|
|
|
2324
3139
|
def _get_memory_context(self, user_id: str, conversation_id: str) -> str:
|
|
2325
3140
|
"""Get relevant memory context for the conversation"""
|
|
@@ -2573,8 +3388,15 @@ class EnhancedNocturnalAgent:
|
|
|
2573
3388
|
|
|
2574
3389
|
# System/technical indicators
|
|
2575
3390
|
system_keywords = [
|
|
2576
|
-
'file', '
|
|
2577
|
-
'
|
|
3391
|
+
'file', 'files', 'directory', 'directories', 'folder', 'folders',
|
|
3392
|
+
'command', 'run', 'execute', 'install',
|
|
3393
|
+
'python', 'code', 'script', 'scripts', 'program', 'system', 'terminal',
|
|
3394
|
+
'find', 'search for', 'locate', 'list', 'show me', 'where is',
|
|
3395
|
+
'what files', 'which files', 'how many files',
|
|
3396
|
+
'grep', 'search', 'look for', 'count',
|
|
3397
|
+
'.py', '.txt', '.js', '.java', '.cpp', '.c', '.h',
|
|
3398
|
+
'function', 'class', 'definition', 'route', 'endpoint',
|
|
3399
|
+
'codebase', 'project structure', 'source code'
|
|
2578
3400
|
]
|
|
2579
3401
|
|
|
2580
3402
|
question_lower = question.lower()
|
|
@@ -2692,7 +3514,7 @@ class EnhancedNocturnalAgent:
|
|
|
2692
3514
|
question_lower = question.lower()
|
|
2693
3515
|
|
|
2694
3516
|
# Pattern 1: Multiple years without SPECIFIC topic (e.g., "2008, 2015, 2019")
|
|
2695
|
-
import re
|
|
3517
|
+
# import re removed - using module-level import
|
|
2696
3518
|
years_pattern = r'\b(19\d{2}|20\d{2})\b'
|
|
2697
3519
|
years = re.findall(years_pattern, question)
|
|
2698
3520
|
if len(years) >= 2:
|
|
@@ -2794,7 +3616,10 @@ IMPORTANT RULES:
|
|
|
2794
3616
|
7. For finding things, use: find ~ -maxdepth 4 -name '*pattern*' 2>/dev/null
|
|
2795
3617
|
8. For creating files: touch filename OR echo "content" > filename
|
|
2796
3618
|
9. For creating directories: mkdir dirname
|
|
2797
|
-
10. ALWAYS include 2>/dev/null to suppress errors from find
|
|
3619
|
+
10. ALWAYS include 2>/dev/null to suppress errors from find and grep
|
|
3620
|
+
11. 🚨 MULTI-STEP QUERIES: For queries like "read X and do Y", ONLY generate the FIRST step (reading X). The LLM will handle subsequent steps after seeing the file contents.
|
|
3621
|
+
12. 🚨 NEVER use python -m py_compile or other code execution for finding bugs - just read the file with cat/head
|
|
3622
|
+
13. 🚨 FOR GREP: When searching in a DIRECTORY (not a specific file), ALWAYS use -r flag for recursive search: grep -rn 'pattern' /path/to/dir 2>/dev/null
|
|
2798
3623
|
|
|
2799
3624
|
Examples:
|
|
2800
3625
|
"where am i?" → {{"action": "execute", "command": "pwd", "reason": "Show current directory", "updates_context": false}}
|
|
@@ -2804,7 +3629,15 @@ Examples:
|
|
|
2804
3629
|
"show me calc.R" → {{"action": "execute", "command": "head -100 calc.R", "reason": "Display file contents", "updates_context": true}}
|
|
2805
3630
|
"create test directory" → {{"action": "execute", "command": "mkdir test && echo 'Created test/'", "reason": "Create new directory", "updates_context": true}}
|
|
2806
3631
|
"create empty config.json" → {{"action": "execute", "command": "touch config.json && echo 'Created config.json'", "reason": "Create empty file", "updates_context": true}}
|
|
2807
|
-
"
|
|
3632
|
+
"write hello.txt with content Hello World" → {{"action": "execute", "command": "echo 'Hello World' > hello.txt", "reason": "Create file with content", "updates_context": true}}
|
|
3633
|
+
"create results.txt with line 1 and line 2" → {{"action": "execute", "command": "echo 'line 1' > results.txt && echo 'line 2' >> results.txt", "reason": "Create file with multiple lines", "updates_context": true}}
|
|
3634
|
+
"fix bug in script.py change OLD to NEW" → {{"action": "execute", "command": "sed -i 's/OLD/NEW/g' script.py && echo 'Fixed script.py'", "reason": "Edit file to fix bug", "updates_context": true}}
|
|
3635
|
+
"search for TODO in py files here" → {{"action": "execute", "command": "grep -n 'TODO' *.py 2>/dev/null", "reason": "Find TODO in current directory py files", "updates_context": false}}
|
|
3636
|
+
"search for TODO in /some/directory" → {{"action": "execute", "command": "grep -rn 'TODO' /some/directory 2>/dev/null", "reason": "Recursively search directory for TODO", "updates_context": false}}
|
|
3637
|
+
"search for TODO comments in /tmp/test" → {{"action": "execute", "command": "grep -rn 'TODO' /tmp/test 2>/dev/null", "reason": "Recursively search directory for TODO", "updates_context": false}}
|
|
3638
|
+
"find all bugs in code" → {{"action": "execute", "command": "grep -rn 'BUG:' . 2>/dev/null", "reason": "Search for bug markers in code", "updates_context": false}}
|
|
3639
|
+
"read analyze.py and find bugs" → {{"action": "execute", "command": "head -200 analyze.py", "reason": "Read file to analyze bugs", "updates_context": false}}
|
|
3640
|
+
"show me calc.py completely" → {{"action": "execute", "command": "cat calc.py", "reason": "Display entire file", "updates_context": false}}
|
|
2808
3641
|
"git status" → {{"action": "execute", "command": "git status", "reason": "Check repository status", "updates_context": false}}
|
|
2809
3642
|
"what's in that file?" + last_file=data.csv → {{"action": "execute", "command": "head -100 data.csv", "reason": "Show file contents", "updates_context": false}}
|
|
2810
3643
|
"hello" → {{"action": "none", "reason": "Conversational greeting, no command needed"}}
|
|
@@ -2816,12 +3649,28 @@ Examples:
|
|
|
2816
3649
|
JSON:"""
|
|
2817
3650
|
|
|
2818
3651
|
try:
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
3652
|
+
# Use LOCAL LLM for planning (don't recurse into call_backend_query)
|
|
3653
|
+
# This avoids infinite recursion and uses temp key if available
|
|
3654
|
+
if hasattr(self, 'client') and self.client:
|
|
3655
|
+
# Local mode with temp key or dev keys
|
|
3656
|
+
# Use gpt-oss-120b for Cerebras (100% test pass, better accuracy)
|
|
3657
|
+
model_name = "gpt-oss-120b" if self.llm_provider == "cerebras" else "llama-3.1-70b-versatile"
|
|
3658
|
+
response = self.client.chat.completions.create(
|
|
3659
|
+
model=model_name,
|
|
3660
|
+
messages=[{"role": "user", "content": planner_prompt}],
|
|
3661
|
+
max_tokens=500,
|
|
3662
|
+
temperature=0.3
|
|
3663
|
+
)
|
|
3664
|
+
plan_text = response.choices[0].message.content.strip()
|
|
3665
|
+
plan_response = ChatResponse(response=plan_text)
|
|
3666
|
+
else:
|
|
3667
|
+
# Backend mode - make a simplified backend call
|
|
3668
|
+
plan_response = await self.call_backend_query(
|
|
3669
|
+
query=planner_prompt,
|
|
3670
|
+
conversation_history=[],
|
|
3671
|
+
api_results={},
|
|
3672
|
+
tools_used=[]
|
|
3673
|
+
)
|
|
2825
3674
|
|
|
2826
3675
|
plan_text = plan_response.response.strip()
|
|
2827
3676
|
if '```' in plan_text:
|
|
@@ -2835,9 +3684,27 @@ JSON:"""
|
|
|
2835
3684
|
|
|
2836
3685
|
if debug_mode:
|
|
2837
3686
|
print(f"🔍 SHELL PLAN: {plan}")
|
|
2838
|
-
|
|
3687
|
+
|
|
2839
3688
|
# GENERIC COMMAND EXECUTION - No more hardcoded actions!
|
|
3689
|
+
if shell_action != "execute" and might_need_shell:
|
|
3690
|
+
command = self._infer_shell_command(request.question)
|
|
3691
|
+
shell_action = "execute"
|
|
3692
|
+
updates_context = False
|
|
3693
|
+
if debug_mode:
|
|
3694
|
+
print(f"🔄 Planner opted out; inferred fallback command: {command}")
|
|
3695
|
+
|
|
3696
|
+
if shell_action == "execute" and not command:
|
|
3697
|
+
command = self._infer_shell_command(request.question)
|
|
3698
|
+
plan["command"] = command
|
|
3699
|
+
if debug_mode:
|
|
3700
|
+
print(f"🔄 Planner omitted command, inferred {command}")
|
|
3701
|
+
|
|
2840
3702
|
if shell_action == "execute" and command:
|
|
3703
|
+
if self._looks_like_user_prompt(command):
|
|
3704
|
+
command = self._infer_shell_command(request.question)
|
|
3705
|
+
plan["command"] = command
|
|
3706
|
+
if debug_mode:
|
|
3707
|
+
print(f"🔄 Replacing delegating plan with command: {command}")
|
|
2841
3708
|
# Check command safety
|
|
2842
3709
|
safety_level = self._classify_command_safety(command)
|
|
2843
3710
|
|
|
@@ -2851,8 +3718,233 @@ JSON:"""
|
|
|
2851
3718
|
"reason": "This command could cause system damage"
|
|
2852
3719
|
}
|
|
2853
3720
|
else:
|
|
2854
|
-
#
|
|
2855
|
-
|
|
3721
|
+
# ========================================
|
|
3722
|
+
# COMMAND INTERCEPTOR: Translate shell commands to file operations
|
|
3723
|
+
# (Claude Code / Cursor parity)
|
|
3724
|
+
# ========================================
|
|
3725
|
+
intercepted = False
|
|
3726
|
+
output = ""
|
|
3727
|
+
|
|
3728
|
+
# Check for file reading commands (cat, head, tail)
|
|
3729
|
+
if command.startswith(('cat ', 'head ', 'tail ')):
|
|
3730
|
+
import shlex
|
|
3731
|
+
try:
|
|
3732
|
+
parts = shlex.split(command)
|
|
3733
|
+
cmd = parts[0]
|
|
3734
|
+
|
|
3735
|
+
# Extract filename (last non-flag argument)
|
|
3736
|
+
filename = None
|
|
3737
|
+
for part in reversed(parts[1:]):
|
|
3738
|
+
if not part.startswith('-'):
|
|
3739
|
+
filename = part
|
|
3740
|
+
break
|
|
3741
|
+
|
|
3742
|
+
if filename:
|
|
3743
|
+
# Use read_file instead of cat/head/tail
|
|
3744
|
+
if cmd == 'head':
|
|
3745
|
+
# head -n 100 file OR head file
|
|
3746
|
+
limit = 100 # default
|
|
3747
|
+
if '-n' in parts or '-' in parts[0]:
|
|
3748
|
+
try:
|
|
3749
|
+
idx = parts.index('-n') if '-n' in parts else 0
|
|
3750
|
+
limit = int(parts[idx + 1])
|
|
3751
|
+
except:
|
|
3752
|
+
pass
|
|
3753
|
+
output = self.read_file(filename, offset=0, limit=limit)
|
|
3754
|
+
elif cmd == 'tail':
|
|
3755
|
+
# For tail, read last N lines (harder, so just read all and show it's tail)
|
|
3756
|
+
output = self.read_file(filename)
|
|
3757
|
+
if "ERROR" not in output:
|
|
3758
|
+
lines = output.split('\n')
|
|
3759
|
+
output = '\n'.join(lines[-100:]) # last 100 lines
|
|
3760
|
+
else: # cat
|
|
3761
|
+
output = self.read_file(filename)
|
|
3762
|
+
|
|
3763
|
+
intercepted = True
|
|
3764
|
+
tools_used.append("read_file")
|
|
3765
|
+
if debug_mode:
|
|
3766
|
+
print(f"🔄 Intercepted: {command} → read_file({filename})")
|
|
3767
|
+
except:
|
|
3768
|
+
pass # Fall back to shell execution
|
|
3769
|
+
|
|
3770
|
+
# Check for file search commands (find)
|
|
3771
|
+
if not intercepted and 'find' in command and '-name' in command:
|
|
3772
|
+
try:
|
|
3773
|
+
# import re removed - using module-level import
|
|
3774
|
+
# Extract pattern: find ... -name '*pattern*'
|
|
3775
|
+
name_match = re.search(r"-name\s+['\"]?\*?([^'\"*\s]+)\*?['\"]?", command)
|
|
3776
|
+
if name_match:
|
|
3777
|
+
pattern = f"**/*{name_match.group(1)}*"
|
|
3778
|
+
path_match = re.search(r"find\s+([^\s]+)", command)
|
|
3779
|
+
search_path = path_match.group(1) if path_match else "."
|
|
3780
|
+
|
|
3781
|
+
result = self.glob_search(pattern, search_path)
|
|
3782
|
+
output = '\n'.join(result['files'][:20]) # Show first 20 matches
|
|
3783
|
+
intercepted = True
|
|
3784
|
+
tools_used.append("glob_search")
|
|
3785
|
+
if debug_mode:
|
|
3786
|
+
print(f"🔄 Intercepted: {command} → glob_search({pattern}, {search_path})")
|
|
3787
|
+
except:
|
|
3788
|
+
pass
|
|
3789
|
+
|
|
3790
|
+
# Check for file writing commands (echo > file, grep > file, etc.) - CHECK THIS FIRST!
|
|
3791
|
+
# This must come BEFORE the plain grep interceptor
|
|
3792
|
+
# BUT: Ignore 2>/dev/null which is error redirection, not file writing
|
|
3793
|
+
if not intercepted and ('>' in command or '>>' in command) and '2>' not in command:
|
|
3794
|
+
try:
|
|
3795
|
+
# import re removed - using module-level import
|
|
3796
|
+
|
|
3797
|
+
# Handle grep ... > file (intercept and execute grep, then write output)
|
|
3798
|
+
if 'grep' in command and '>' in command:
|
|
3799
|
+
# Extract: grep -rn 'pattern' path > output.txt
|
|
3800
|
+
grep_match = re.search(r"grep\s+(.*)\s>\s*(\S+)", command)
|
|
3801
|
+
if grep_match:
|
|
3802
|
+
grep_part = grep_match.group(1).strip()
|
|
3803
|
+
output_file = grep_match.group(2)
|
|
3804
|
+
|
|
3805
|
+
# Extract pattern and options from grep command
|
|
3806
|
+
pattern_match = re.search(r"['\"]([^'\"]+)['\"]", grep_part)
|
|
3807
|
+
if pattern_match:
|
|
3808
|
+
pattern = pattern_match.group(1)
|
|
3809
|
+
search_path = "."
|
|
3810
|
+
file_pattern = "*.py" if "*.py" in command else "*"
|
|
3811
|
+
|
|
3812
|
+
if debug_mode:
|
|
3813
|
+
print(f"🔄 Intercepted: {command} → grep_search('{pattern}', '{search_path}', '{file_pattern}') + write_file({output_file})")
|
|
3814
|
+
|
|
3815
|
+
# Execute grep_search
|
|
3816
|
+
try:
|
|
3817
|
+
grep_result = self.grep_search(
|
|
3818
|
+
pattern=pattern,
|
|
3819
|
+
path=search_path,
|
|
3820
|
+
file_pattern=file_pattern,
|
|
3821
|
+
output_mode="content"
|
|
3822
|
+
)
|
|
3823
|
+
|
|
3824
|
+
# Format matches as text (like grep -rn output)
|
|
3825
|
+
output_lines = []
|
|
3826
|
+
for file_path, matches in grep_result.get('matches', {}).items():
|
|
3827
|
+
for line_num, line_content in matches:
|
|
3828
|
+
output_lines.append(f"{file_path}:{line_num}:{line_content}")
|
|
3829
|
+
|
|
3830
|
+
content_to_write = '\n'.join(output_lines) if output_lines else "(no matches found)"
|
|
3831
|
+
|
|
3832
|
+
# Write grep output to file
|
|
3833
|
+
write_result = self.write_file(output_file, content_to_write)
|
|
3834
|
+
if write_result['success']:
|
|
3835
|
+
output = f"Found {len(output_lines)} lines with '{pattern}' → Created {output_file} ({write_result['bytes_written']} bytes)"
|
|
3836
|
+
intercepted = True
|
|
3837
|
+
tools_used.extend(["grep_search", "write_file"])
|
|
3838
|
+
except Exception as e:
|
|
3839
|
+
if debug_mode:
|
|
3840
|
+
print(f"⚠️ Grep > file interception error: {e}")
|
|
3841
|
+
# Fall back to normal execution
|
|
3842
|
+
pass
|
|
3843
|
+
|
|
3844
|
+
# Extract: echo 'content' > filename OR cat << EOF > filename
|
|
3845
|
+
if not intercepted and 'echo' in command and '>' in command:
|
|
3846
|
+
# echo 'content' > file OR echo "content" > file
|
|
3847
|
+
match = re.search(r"echo\s+['\"](.+?)['\"].*?>\s*(\S+)", command)
|
|
3848
|
+
if match:
|
|
3849
|
+
content = match.group(1)
|
|
3850
|
+
filename = match.group(2)
|
|
3851
|
+
# Unescape common sequences
|
|
3852
|
+
content = content.replace('\\n', '\n').replace('\\t', '\t')
|
|
3853
|
+
result = self.write_file(filename, content + '\n')
|
|
3854
|
+
if result['success']:
|
|
3855
|
+
output = f"Created {filename} ({result['bytes_written']} bytes)"
|
|
3856
|
+
intercepted = True
|
|
3857
|
+
tools_used.append("write_file")
|
|
3858
|
+
if debug_mode:
|
|
3859
|
+
print(f"🔄 Intercepted: {command} → write_file({filename}, ...)")
|
|
3860
|
+
except:
|
|
3861
|
+
pass
|
|
3862
|
+
|
|
3863
|
+
# Check for sed editing commands
|
|
3864
|
+
if not intercepted and command.startswith('sed '):
|
|
3865
|
+
try:
|
|
3866
|
+
# import re removed - using module-level import
|
|
3867
|
+
# sed 's/old/new/g' file OR sed -i 's/old/new/' file
|
|
3868
|
+
match = re.search(r"sed.*?['\"]s/([^/]+)/([^/]+)/", command)
|
|
3869
|
+
if match:
|
|
3870
|
+
old_text = match.group(1)
|
|
3871
|
+
new_text = match.group(2)
|
|
3872
|
+
# Extract filename (last argument)
|
|
3873
|
+
parts = command.split()
|
|
3874
|
+
filename = parts[-1]
|
|
3875
|
+
|
|
3876
|
+
# Determine if replace_all based on /g flag
|
|
3877
|
+
replace_all = '/g' in command
|
|
3878
|
+
|
|
3879
|
+
result = self.edit_file(filename, old_text, new_text, replace_all=replace_all)
|
|
3880
|
+
if result['success']:
|
|
3881
|
+
output = result['message']
|
|
3882
|
+
intercepted = True
|
|
3883
|
+
tools_used.append("edit_file")
|
|
3884
|
+
if debug_mode:
|
|
3885
|
+
print(f"🔄 Intercepted: {command} → edit_file({filename}, {old_text}, {new_text})")
|
|
3886
|
+
except:
|
|
3887
|
+
pass
|
|
3888
|
+
|
|
3889
|
+
# Check for heredoc file creation (cat << EOF > file)
|
|
3890
|
+
if not intercepted and '<<' in command and ('EOF' in command or 'HEREDOC' in command):
|
|
3891
|
+
try:
|
|
3892
|
+
# import re removed - using module-level import
|
|
3893
|
+
# Extract: cat << EOF > filename OR cat > filename << EOF
|
|
3894
|
+
# Note: We can't actually get the heredoc content from a single command line
|
|
3895
|
+
# This would need to be handled differently (multi-line input)
|
|
3896
|
+
# For now, just detect and warn
|
|
3897
|
+
if debug_mode:
|
|
3898
|
+
print(f"⚠️ Heredoc detected but not intercepted: {command[:80]}")
|
|
3899
|
+
except:
|
|
3900
|
+
pass
|
|
3901
|
+
|
|
3902
|
+
# Check for content search commands (grep -r) WITHOUT redirection
|
|
3903
|
+
# This comes AFTER grep > file interceptor to avoid conflicts
|
|
3904
|
+
if not intercepted and 'grep' in command and ('-r' in command or '-R' in command):
|
|
3905
|
+
try:
|
|
3906
|
+
# import re removed - using module-level import
|
|
3907
|
+
# Extract pattern: grep -r 'pattern' path
|
|
3908
|
+
pattern_match = re.search(r"grep.*?['\"]([^'\"]+)['\"]", command)
|
|
3909
|
+
if pattern_match:
|
|
3910
|
+
pattern = pattern_match.group(1)
|
|
3911
|
+
# Extract path - skip flags and options
|
|
3912
|
+
parts = [p for p in command.split() if not p.startswith('-') and p != 'grep' and p != '2>/dev/null']
|
|
3913
|
+
# Path is after pattern (skip the quoted pattern)
|
|
3914
|
+
search_path = parts[-1] if len(parts) >= 2 else "."
|
|
3915
|
+
|
|
3916
|
+
# Detect file pattern from command (e.g., *.py, *.txt) or use *
|
|
3917
|
+
file_pattern = "*"
|
|
3918
|
+
if '*.py' in command:
|
|
3919
|
+
file_pattern = "*.py"
|
|
3920
|
+
elif '*.txt' in command:
|
|
3921
|
+
file_pattern = "*.txt"
|
|
3922
|
+
|
|
3923
|
+
result = self.grep_search(pattern, search_path, file_pattern, output_mode="content")
|
|
3924
|
+
|
|
3925
|
+
# Format grep results
|
|
3926
|
+
if 'matches' in result and result['matches']:
|
|
3927
|
+
output_parts = []
|
|
3928
|
+
for file_path, matches in result['matches'].items():
|
|
3929
|
+
output_parts.append(f"{file_path}:")
|
|
3930
|
+
for line_num, line_content in matches[:10]: # Limit per file
|
|
3931
|
+
output_parts.append(f" {line_num}: {line_content}")
|
|
3932
|
+
output = '\n'.join(output_parts)
|
|
3933
|
+
else:
|
|
3934
|
+
output = f"No matches found for '{pattern}'"
|
|
3935
|
+
|
|
3936
|
+
intercepted = True
|
|
3937
|
+
tools_used.append("grep_search")
|
|
3938
|
+
if debug_mode:
|
|
3939
|
+
print(f"🔄 Intercepted: {command} → grep_search({pattern}, {search_path}, {file_pattern})")
|
|
3940
|
+
except Exception as e:
|
|
3941
|
+
if debug_mode:
|
|
3942
|
+
print(f"⚠️ Grep interceptor failed: {e}")
|
|
3943
|
+
pass
|
|
3944
|
+
|
|
3945
|
+
# If not intercepted, execute as shell command
|
|
3946
|
+
if not intercepted:
|
|
3947
|
+
output = self.execute_command(command)
|
|
2856
3948
|
|
|
2857
3949
|
if not output.startswith("ERROR"):
|
|
2858
3950
|
# Success - store results
|
|
@@ -2866,7 +3958,7 @@ JSON:"""
|
|
|
2866
3958
|
|
|
2867
3959
|
# Update file context if needed
|
|
2868
3960
|
if updates_context:
|
|
2869
|
-
import re
|
|
3961
|
+
# import re removed - using module-level import
|
|
2870
3962
|
# Extract file paths from command
|
|
2871
3963
|
file_patterns = r'([a-zA-Z0-9_\-./]+\.(py|r|csv|txt|json|md|ipynb|rmd))'
|
|
2872
3964
|
files_mentioned = re.findall(file_patterns, command, re.IGNORECASE)
|
|
@@ -2964,7 +4056,7 @@ JSON:"""
|
|
|
2964
4056
|
|
|
2965
4057
|
elif shell_action == "read_file":
|
|
2966
4058
|
# NEW: Read and inspect file (R, Python, CSV, etc.)
|
|
2967
|
-
import re
|
|
4059
|
+
# import re removed - using module-level import
|
|
2968
4060
|
|
|
2969
4061
|
file_path = plan.get("file_path", "")
|
|
2970
4062
|
if not file_path and might_need_shell:
|
|
@@ -3068,58 +4160,22 @@ JSON:"""
|
|
|
3068
4160
|
|
|
3069
4161
|
# FinSight API for financial data - Use LLM for ticker/metric extraction
|
|
3070
4162
|
if "finsight" in request_analysis.get("apis", []):
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
- "Microsoft profit" → {{"tickers": ["MSFT"], "metric": "netIncome"}}
|
|
3087
|
-
|
|
3088
|
-
JSON:"""
|
|
4163
|
+
session_key = f"{request.user_id}:{request.conversation_id}"
|
|
4164
|
+
tickers, metrics_to_fetch = self._plan_financial_request(request.question, session_key)
|
|
4165
|
+
financial_payload: Dict[str, Any] = {}
|
|
4166
|
+
|
|
4167
|
+
for ticker in tickers:
|
|
4168
|
+
result = await self.get_financial_metrics(ticker, metrics_to_fetch)
|
|
4169
|
+
financial_payload[ticker] = result
|
|
4170
|
+
|
|
4171
|
+
if financial_payload:
|
|
4172
|
+
self._session_topics[session_key] = {
|
|
4173
|
+
"tickers": tickers,
|
|
4174
|
+
"metrics": metrics_to_fetch,
|
|
4175
|
+
}
|
|
4176
|
+
api_results["financial"] = financial_payload
|
|
4177
|
+
tools_used.append("finsight_api")
|
|
3089
4178
|
|
|
3090
|
-
try:
|
|
3091
|
-
finance_response = await self.call_backend_query(
|
|
3092
|
-
query=finance_prompt,
|
|
3093
|
-
conversation_history=[],
|
|
3094
|
-
api_results={},
|
|
3095
|
-
tools_used=[]
|
|
3096
|
-
)
|
|
3097
|
-
|
|
3098
|
-
import json as json_module
|
|
3099
|
-
finance_text = finance_response.response.strip()
|
|
3100
|
-
if '```' in finance_text:
|
|
3101
|
-
finance_text = finance_text.split('```')[1].replace('json', '').strip()
|
|
3102
|
-
|
|
3103
|
-
finance_plan = json_module.loads(finance_text)
|
|
3104
|
-
tickers = finance_plan.get("tickers", [])
|
|
3105
|
-
metric = finance_plan.get("metric", "revenue")
|
|
3106
|
-
|
|
3107
|
-
if debug_mode:
|
|
3108
|
-
print(f"🔍 LLM FINANCE PLAN: tickers={tickers}, metric={metric}")
|
|
3109
|
-
|
|
3110
|
-
if tickers:
|
|
3111
|
-
# Call FinSight with extracted ticker + metric
|
|
3112
|
-
financial_data = await self._call_finsight_api(f"calc/{tickers[0]}/{metric}")
|
|
3113
|
-
if debug_mode:
|
|
3114
|
-
print(f"🔍 FinSight returned: {list(financial_data.keys()) if financial_data else None}")
|
|
3115
|
-
if financial_data and "error" not in financial_data:
|
|
3116
|
-
api_results["financial"] = financial_data
|
|
3117
|
-
tools_used.append("finsight_api")
|
|
3118
|
-
|
|
3119
|
-
except Exception as e:
|
|
3120
|
-
if debug_mode:
|
|
3121
|
-
print(f"🔍 Finance LLM extraction failed: {e}")
|
|
3122
|
-
|
|
3123
4179
|
# ========================================================================
|
|
3124
4180
|
# PRIORITY 3: WEB SEARCH (Fallback - only if shell didn't handle AND no data yet)
|
|
3125
4181
|
# ========================================================================
|
|
@@ -3202,12 +4258,27 @@ Respond with JSON:
|
|
|
3202
4258
|
JSON:"""
|
|
3203
4259
|
|
|
3204
4260
|
try:
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
4261
|
+
# Use LOCAL LLM for web search decision (avoid recursion)
|
|
4262
|
+
if hasattr(self, 'client') and self.client:
|
|
4263
|
+
# Local mode
|
|
4264
|
+
# Use gpt-oss-120b for Cerebras (100% test pass, better accuracy)
|
|
4265
|
+
model_name = "gpt-oss-120b" if self.llm_provider == "cerebras" else "llama-3.1-70b-versatile"
|
|
4266
|
+
response = self.client.chat.completions.create(
|
|
4267
|
+
model=model_name,
|
|
4268
|
+
messages=[{"role": "user", "content": web_decision_prompt}],
|
|
4269
|
+
max_tokens=300,
|
|
4270
|
+
temperature=0.2
|
|
4271
|
+
)
|
|
4272
|
+
decision_text = response.choices[0].message.content.strip()
|
|
4273
|
+
web_decision_response = ChatResponse(response=decision_text)
|
|
4274
|
+
else:
|
|
4275
|
+
# Backend mode
|
|
4276
|
+
web_decision_response = await self.call_backend_query(
|
|
4277
|
+
query=web_decision_prompt,
|
|
4278
|
+
conversation_history=[],
|
|
4279
|
+
api_results={},
|
|
4280
|
+
tools_used=[]
|
|
4281
|
+
)
|
|
3211
4282
|
|
|
3212
4283
|
import json as json_module
|
|
3213
4284
|
decision_text = web_decision_response.response.strip()
|
|
@@ -3245,12 +4316,48 @@ JSON:"""
|
|
|
3245
4316
|
api_results=api_results,
|
|
3246
4317
|
tools_used=tools_used
|
|
3247
4318
|
)
|
|
3248
|
-
|
|
3249
|
-
#
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
4319
|
+
|
|
4320
|
+
# POST-PROCESSING: Auto-extract code blocks and write files if user requested file creation
|
|
4321
|
+
# This fixes the issue where LLM shows corrected code but doesn't create the file
|
|
4322
|
+
if any(keyword in request.question.lower() for keyword in ['create', 'write', 'save', 'generate', 'fixed', 'corrected']):
|
|
4323
|
+
# Extract filename from query (e.g., "write to foo.py", "create bar_fixed.py")
|
|
4324
|
+
# Note: re is already imported at module level (line 12)
|
|
4325
|
+
filename_match = re.search(r'(?:to|create|write|save|generate)\s+(\w+[._-]\w+\.[\w]+)', request.question, re.IGNORECASE)
|
|
4326
|
+
if not filename_match:
|
|
4327
|
+
# Try pattern: "foo_fixed.py" or "bar.py"
|
|
4328
|
+
filename_match = re.search(r'(\w+_fixed\.[\w]+|\w+\.[\w]+)', request.question)
|
|
4329
|
+
|
|
4330
|
+
if filename_match:
|
|
4331
|
+
target_filename = filename_match.group(1)
|
|
4332
|
+
|
|
4333
|
+
# Extract code block from response (```python ... ``` or ``` ... ```)
|
|
4334
|
+
code_block_pattern = r'```(?:python|bash|sh|r|sql)?\n(.*?)```'
|
|
4335
|
+
code_blocks = re.findall(code_block_pattern, response.response, re.DOTALL)
|
|
4336
|
+
|
|
4337
|
+
if code_blocks:
|
|
4338
|
+
# Use the LARGEST code block (likely the complete file)
|
|
4339
|
+
largest_block = max(code_blocks, key=len)
|
|
4340
|
+
|
|
4341
|
+
# Write to file
|
|
4342
|
+
try:
|
|
4343
|
+
write_result = self.write_file(target_filename, largest_block)
|
|
4344
|
+
if write_result['success']:
|
|
4345
|
+
# Append confirmation to response
|
|
4346
|
+
response.response += f"\n\n✅ File created: {target_filename} ({write_result['bytes_written']} bytes)"
|
|
4347
|
+
if debug_mode:
|
|
4348
|
+
print(f"🔄 Auto-extracted code block → write_file({target_filename})")
|
|
4349
|
+
except Exception as e:
|
|
4350
|
+
if debug_mode:
|
|
4351
|
+
print(f"⚠️ Auto-write failed: {e}")
|
|
4352
|
+
|
|
4353
|
+
return self._finalize_interaction(
|
|
4354
|
+
request,
|
|
4355
|
+
response,
|
|
4356
|
+
tools_used,
|
|
4357
|
+
api_results,
|
|
4358
|
+
request_analysis,
|
|
4359
|
+
log_workflow=False,
|
|
4360
|
+
)
|
|
3254
4361
|
|
|
3255
4362
|
# DEV MODE ONLY: Direct Groq calls (only works with local API keys)
|
|
3256
4363
|
# This code path won't execute in production since self.client = None
|
|
@@ -3285,6 +4392,26 @@ JSON:"""
|
|
|
3285
4392
|
|
|
3286
4393
|
# Get memory context
|
|
3287
4394
|
memory_context = self._get_memory_context(request.user_id, request.conversation_id)
|
|
4395
|
+
archive_context = self.archive.get_recent_context(
|
|
4396
|
+
request.user_id,
|
|
4397
|
+
request.conversation_id,
|
|
4398
|
+
limit=3,
|
|
4399
|
+
) if getattr(self, "archive", None) else ""
|
|
4400
|
+
if archive_context:
|
|
4401
|
+
if memory_context:
|
|
4402
|
+
memory_context = f"{memory_context}\n\n{archive_context}"
|
|
4403
|
+
else:
|
|
4404
|
+
memory_context = archive_context
|
|
4405
|
+
archive_context = self.archive.get_recent_context(
|
|
4406
|
+
request.user_id,
|
|
4407
|
+
request.conversation_id,
|
|
4408
|
+
limit=3,
|
|
4409
|
+
) if getattr(self, "archive", None) else ""
|
|
4410
|
+
if archive_context:
|
|
4411
|
+
if memory_context:
|
|
4412
|
+
memory_context = f"{memory_context}\n\n{archive_context}"
|
|
4413
|
+
else:
|
|
4414
|
+
memory_context = archive_context
|
|
3288
4415
|
|
|
3289
4416
|
# Ultra-light handling for small talk to save tokens entirely
|
|
3290
4417
|
if self._is_simple_greeting(request.question):
|
|
@@ -3390,44 +4517,17 @@ JSON:"""
|
|
|
3390
4517
|
return self._respond_with_workspace_listing(request, workspace_listing)
|
|
3391
4518
|
|
|
3392
4519
|
if "finsight" in request_analysis["apis"]:
|
|
3393
|
-
# Extract tickers from symbols or company names
|
|
3394
|
-
tickers = self._extract_tickers_from_text(request.question)
|
|
3395
|
-
financial_payload = {}
|
|
3396
4520
|
session_key = f"{request.user_id}:{request.conversation_id}"
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
tickers = tickers + ["MSFT"] if "AAPL" in tickers else ["MSFT"]
|
|
3404
|
-
|
|
3405
|
-
# Determine which metrics to fetch based on query keywords
|
|
3406
|
-
metrics_to_fetch = []
|
|
3407
|
-
if any(kw in question_lower for kw in ["revenue", "sales", "top line"]):
|
|
3408
|
-
metrics_to_fetch.append("revenue")
|
|
3409
|
-
if any(kw in question_lower for kw in ["gross profit", "gross margin", "margin"]):
|
|
3410
|
-
metrics_to_fetch.append("grossProfit")
|
|
3411
|
-
if any(kw in question_lower for kw in ["operating income", "operating profit", "ebit"]):
|
|
3412
|
-
metrics_to_fetch.append("operatingIncome")
|
|
3413
|
-
if any(kw in question_lower for kw in ["net income", "profit", "earnings", "bottom line"]):
|
|
3414
|
-
metrics_to_fetch.append("netIncome")
|
|
3415
|
-
|
|
3416
|
-
# Default to key metrics if no specific request
|
|
3417
|
-
if not metrics_to_fetch and last_topic and last_topic.get("metrics"):
|
|
3418
|
-
metrics_to_fetch = list(last_topic["metrics"])
|
|
3419
|
-
|
|
3420
|
-
if not metrics_to_fetch:
|
|
3421
|
-
metrics_to_fetch = ["revenue", "grossProfit"]
|
|
3422
|
-
|
|
3423
|
-
# Fetch metrics for each ticker (cap 2 tickers)
|
|
3424
|
-
for t in tickers[:2]:
|
|
3425
|
-
result = await self.get_financial_metrics(t, metrics_to_fetch)
|
|
3426
|
-
financial_payload[t] = result
|
|
4521
|
+
tickers, metrics_to_fetch = self._plan_financial_request(request.question, session_key)
|
|
4522
|
+
financial_payload: Dict[str, Any] = {}
|
|
4523
|
+
|
|
4524
|
+
for ticker in tickers:
|
|
4525
|
+
result = await self.get_financial_metrics(ticker, metrics_to_fetch)
|
|
4526
|
+
financial_payload[ticker] = result
|
|
3427
4527
|
|
|
3428
4528
|
if financial_payload:
|
|
3429
4529
|
self._session_topics[session_key] = {
|
|
3430
|
-
"tickers": tickers
|
|
4530
|
+
"tickers": tickers,
|
|
3431
4531
|
"metrics": metrics_to_fetch,
|
|
3432
4532
|
}
|
|
3433
4533
|
direct_finance = (
|
|
@@ -3501,7 +4601,18 @@ JSON:"""
|
|
|
3501
4601
|
summary_tokens = summary_response.usage.total_tokens
|
|
3502
4602
|
self._charge_tokens(request.user_id, summary_tokens)
|
|
3503
4603
|
self.total_cost += (summary_tokens / 1000) * self.cost_per_1k_tokens
|
|
4604
|
+
else:
|
|
4605
|
+
summary_tokens = 0
|
|
3504
4606
|
messages.append({"role": "system", "content": f"Previous conversation summary: {conversation_summary}"})
|
|
4607
|
+
self._emit_telemetry(
|
|
4608
|
+
"history_summarized",
|
|
4609
|
+
request,
|
|
4610
|
+
success=True,
|
|
4611
|
+
extra={
|
|
4612
|
+
"history_length": len(self.conversation_history),
|
|
4613
|
+
"summary_tokens": summary_tokens,
|
|
4614
|
+
},
|
|
4615
|
+
)
|
|
3505
4616
|
except:
|
|
3506
4617
|
# If summary fails, just use recent history
|
|
3507
4618
|
pass
|
|
@@ -3665,29 +4776,35 @@ JSON:"""
|
|
|
3665
4776
|
if footer:
|
|
3666
4777
|
final_response = f"{final_response}\n\n_{footer}_"
|
|
3667
4778
|
|
|
3668
|
-
#
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
4779
|
+
# TRUTH-SEEKING VERIFICATION: Check if response matches actual shell output
|
|
4780
|
+
if "shell_info" in api_results and api_results["shell_info"]:
|
|
4781
|
+
shell_output = api_results["shell_info"].get("output", "")
|
|
4782
|
+
|
|
4783
|
+
# If shell output was empty or says "no results", but response lists specific items
|
|
4784
|
+
# This indicates hallucination
|
|
4785
|
+
if (not shell_output or "no" in shell_output.lower() and "found" in shell_output.lower()):
|
|
4786
|
+
# Check if response contains made-up file paths or code
|
|
4787
|
+
response_lower = final_response.lower()
|
|
4788
|
+
if any(indicator in response_lower for indicator in [".py:", "found in", "route", "@app", "@router", "file1", "file2"]):
|
|
4789
|
+
# Hallucination detected - replace with honest answer
|
|
4790
|
+
final_response = "I searched but found no matches. The search returned no results."
|
|
4791
|
+
logger.warning("🚨 Hallucination prevented: LLM tried to make up results when shell output was empty")
|
|
4792
|
+
|
|
4793
|
+
expected_tools: Set[str] = set()
|
|
4794
|
+
if "finsight" in request_analysis.get("apis", []):
|
|
4795
|
+
expected_tools.add("finsight_api")
|
|
4796
|
+
if "archive" in request_analysis.get("apis", []):
|
|
4797
|
+
expected_tools.add("archive_api")
|
|
4798
|
+
for expected in expected_tools:
|
|
4799
|
+
if expected not in tools_used:
|
|
4800
|
+
self._emit_telemetry(
|
|
4801
|
+
"tool_missing",
|
|
4802
|
+
request,
|
|
4803
|
+
success=False,
|
|
4804
|
+
extra={"expected": expected},
|
|
4805
|
+
)
|
|
4806
|
+
|
|
4807
|
+
response_obj = ChatResponse(
|
|
3691
4808
|
response=final_response,
|
|
3692
4809
|
tools_used=tools_used,
|
|
3693
4810
|
reasoning_steps=[f"Request type: {request_analysis['type']}", f"APIs used: {request_analysis['apis']}"],
|
|
@@ -3697,9 +4814,22 @@ JSON:"""
|
|
|
3697
4814
|
execution_results=execution_results,
|
|
3698
4815
|
api_results=api_results
|
|
3699
4816
|
)
|
|
4817
|
+
return self._finalize_interaction(
|
|
4818
|
+
request,
|
|
4819
|
+
response_obj,
|
|
4820
|
+
tools_used,
|
|
4821
|
+
api_results,
|
|
4822
|
+
request_analysis,
|
|
4823
|
+
log_workflow=True,
|
|
4824
|
+
)
|
|
3700
4825
|
|
|
3701
4826
|
except Exception as e:
|
|
4827
|
+
import traceback
|
|
3702
4828
|
details = str(e)
|
|
4829
|
+
debug_mode = os.getenv("NOCTURNAL_DEBUG", "").lower() == "1"
|
|
4830
|
+
if debug_mode:
|
|
4831
|
+
print("🔴 FULL TRACEBACK:")
|
|
4832
|
+
traceback.print_exc()
|
|
3703
4833
|
message = (
|
|
3704
4834
|
"⚠️ Something went wrong while orchestrating your request, but no actions were performed. "
|
|
3705
4835
|
"Please retry, and if the issue persists share this detail with the team: {details}."
|
|
@@ -3842,24 +4972,13 @@ JSON:"""
|
|
|
3842
4972
|
|
|
3843
4973
|
# FinSight API (abbreviated)
|
|
3844
4974
|
if "finsight" in request_analysis["apis"]:
|
|
3845
|
-
|
|
4975
|
+
session_key = f"{request.user_id}:{request.conversation_id}"
|
|
4976
|
+
tickers, metrics_to_fetch = self._plan_financial_request(request.question, session_key)
|
|
3846
4977
|
financial_payload = {}
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
if "microsoft" in question_lower:
|
|
3852
|
-
tickers = ["MSFT"] if not tickers else tickers + ["MSFT"]
|
|
3853
|
-
|
|
3854
|
-
metrics_to_fetch = ["revenue", "grossProfit"]
|
|
3855
|
-
if any(kw in question_lower for kw in ["revenue", "sales"]):
|
|
3856
|
-
metrics_to_fetch = ["revenue"]
|
|
3857
|
-
if any(kw in question_lower for kw in ["profit", "margin"]):
|
|
3858
|
-
metrics_to_fetch.append("grossProfit")
|
|
3859
|
-
|
|
3860
|
-
for t in tickers[:2]:
|
|
3861
|
-
result = await self.get_financial_metrics(t, metrics_to_fetch)
|
|
3862
|
-
financial_payload[t] = result
|
|
4978
|
+
|
|
4979
|
+
for ticker in tickers:
|
|
4980
|
+
result = await self.get_financial_metrics(ticker, metrics_to_fetch)
|
|
4981
|
+
financial_payload[ticker] = result
|
|
3863
4982
|
|
|
3864
4983
|
if financial_payload:
|
|
3865
4984
|
api_results["financial"] = financial_payload
|