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.
@@ -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
- "You can read files, run scripts, perform calculations, and show results."
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. Execute code for analysis, calculations, and file operations. "
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
- "When user asks about files, directories, or data: EXECUTE commands to find answers."
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": "llama-3.3-70b", # Cerebras 70B 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": "llama-3.3-70b-versatile",
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 → ALWAYS use backend mode (ignore USE_LOCAL_KEYS)
1500
- use_local_keys = False
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 - load Cerebras API keys (primary) with Groq fallback
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
- # Load Cerebras keys from environment (PRIMARY)
1553
- self.api_keys = []
1554
- for i in range(1, 10): # Check CEREBRAS_API_KEY_1 through CEREBRAS_API_KEY_9
1555
- key = os.getenv(f"CEREBRAS_API_KEY_{i}") or os.getenv(f"CEREBRAS_API_KEY")
1556
- if key and key not in self.api_keys:
1557
- self.api_keys.append(key)
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
- ['bash'],
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": "llama-3.3-70b", # Compatible with Cerebras (priority) and Groq
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', 'llama-3.3-70b'),
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', 'llama-3.3-70b-versatile'),
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
- full_command = f"{command}; echo '{marker}'\n"
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', 'directory', 'command', 'run', 'execute', 'install',
2577
- 'python', 'code', 'script', 'program', 'system', 'terminal'
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
- "search for TODO in py files" → {{"action": "execute", "command": "grep -n 'TODO' *.py 2>/dev/null", "reason": "Find TODO comments", "updates_context": false}}
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
- plan_response = await self.call_backend_query(
2820
- query=planner_prompt,
2821
- conversation_history=[],
2822
- api_results={},
2823
- tools_used=[]
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
- # Execute the command
2855
- output = self.execute_command(command)
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 # Import at function level
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
- # LLM extracts ticker + metric (more accurate than regex)
3072
- finance_prompt = f"""Extract financial query details from user's question.
3073
-
3074
- User query: "{request.question}"
3075
-
3076
- Respond with JSON:
3077
- {{
3078
- "tickers": ["AAPL", "TSLA"] (stock symbols - infer from company names if needed),
3079
- "metric": "revenue|marketCap|price|netIncome|eps|freeCashFlow|grossProfit"
3080
- }}
3081
-
3082
- Examples:
3083
- - "Tesla revenue" → {{"tickers": ["TSLA"], "metric": "revenue"}}
3084
- - "What's Apple worth?" → {{"tickers": ["AAPL"], "metric": "marketCap"}}
3085
- - "tsla stock price" → {{"tickers": ["TSLA"], "metric": "price"}}
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
- web_decision_response = await self.call_backend_query(
3206
- query=web_decision_prompt,
3207
- conversation_history=[],
3208
- api_results={},
3209
- tools_used=[]
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
- # CRITICAL: Save to conversation history
3250
- self.conversation_history.append({"role": "user", "content": request.question})
3251
- self.conversation_history.append({"role": "assistant", "content": response.response})
3252
-
3253
- return response
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
- last_topic = self._session_topics.get(session_key)
3398
- if not tickers:
3399
- # Heuristic defaults for common requests
3400
- if "apple" in request.question.lower():
3401
- tickers = ["AAPL"]
3402
- if "microsoft" in request.question.lower():
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[:2],
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
- # Update conversation history
3669
- self.conversation_history.append({"role": "user", "content": request.question})
3670
- self.conversation_history.append({"role": "assistant", "content": final_response})
3671
-
3672
- # Update memory
3673
- self._update_memory(
3674
- request.user_id,
3675
- request.conversation_id,
3676
- f"Q: {request.question[:100]}... A: {final_response[:100]}..."
3677
- )
3678
-
3679
- # Save to workflow history automatically
3680
- self.workflow.save_query_result(
3681
- query=request.question,
3682
- response=final_response,
3683
- metadata={
3684
- "tools_used": tools_used,
3685
- "tokens_used": tokens_used,
3686
- "confidence_score": request_analysis['confidence']
3687
- }
3688
- )
3689
-
3690
- return ChatResponse(
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
- tickers = self._extract_tickers_from_text(request.question)
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
- if not tickers:
3849
- if "apple" in question_lower:
3850
- tickers = ["AAPL"]
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