srcodex 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. srcodex/__init__.py +0 -0
  2. srcodex/backend/__init__.py +0 -0
  3. srcodex/backend/chat.py +79 -0
  4. srcodex/backend/main.py +98 -0
  5. srcodex/backend/services/__init__.py +0 -0
  6. srcodex/backend/services/claude_service.py +754 -0
  7. srcodex/backend/services/config_loader.py +113 -0
  8. srcodex/backend/services/file_access_tools.py +279 -0
  9. srcodex/backend/services/file_tree.py +480 -0
  10. srcodex/backend/services/graph_tools.py +874 -0
  11. srcodex/backend/services/logger_setup.py +91 -0
  12. srcodex/backend/services/session_manager.py +81 -0
  13. srcodex/backend/services/status_tracker.py +91 -0
  14. srcodex/cli.py +255 -0
  15. srcodex/core/__init__.py +0 -0
  16. srcodex/core/config.py +113 -0
  17. srcodex/core/logger.py +23 -0
  18. srcodex/indexer/__init__.py +0 -0
  19. srcodex/indexer/cscope_client.py +183 -0
  20. srcodex/indexer/ctags_compat.py +223 -0
  21. srcodex/indexer/ctags_parser.py +456 -0
  22. srcodex/indexer/explorer.py +135 -0
  23. srcodex/indexer/field_access_analyzer.py +436 -0
  24. srcodex/indexer/indexer.py +664 -0
  25. srcodex/indexer/reference_ingestor.py +293 -0
  26. srcodex/indexer/reference_resolver.py +544 -0
  27. srcodex/tui/__init__.py +0 -0
  28. srcodex/tui/app.py +103 -0
  29. srcodex/tui/app.tcss +24 -0
  30. srcodex/tui/components/__init__.py +0 -0
  31. srcodex/tui/components/bars/__init__.py +0 -0
  32. srcodex/tui/components/bars/chat_header.py +48 -0
  33. srcodex/tui/components/bars/code_tab_bar.py +157 -0
  34. srcodex/tui/components/bars/footer_bar.py +128 -0
  35. srcodex/tui/components/bars/left_tab.py +54 -0
  36. srcodex/tui/components/logger.py +57 -0
  37. srcodex/tui/components/panels/__init__.py +0 -0
  38. srcodex/tui/components/panels/chat_panel.py +523 -0
  39. srcodex/tui/components/panels/code_panel.py +229 -0
  40. srcodex/tui/components/panels/side_panel.py +128 -0
  41. srcodex/tui/components/views/__init__.py +0 -0
  42. srcodex/tui/components/views/explorer_view.py +20 -0
  43. srcodex/tui/components/views/search_view.py +148 -0
  44. srcodex/tui/components/widgets/__init__.py +0 -0
  45. srcodex/tui/components/widgets/file_browser.py +16 -0
  46. srcodex/tui/components/widgets/find_box.py +85 -0
  47. srcodex-0.2.0.dist-info/METADATA +170 -0
  48. srcodex-0.2.0.dist-info/RECORD +52 -0
  49. srcodex-0.2.0.dist-info/WHEEL +5 -0
  50. srcodex-0.2.0.dist-info/entry_points.txt +2 -0
  51. srcodex-0.2.0.dist-info/licenses/LICENSE +21 -0
  52. srcodex-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,754 @@
1
+ import os
2
+ import logging
3
+ from anthropic import Anthropic, APIError, APIStatusError
4
+ from .file_access_tools import TOOL_DEFINITIONS as FILE_TOOLS, execute_tool as execute_file_tool
5
+ from .graph_tools import TOOLS as GRAPH_TOOLS, execute_graph_tool
6
+ from .config_loader import get_config
7
+ from .status_tracker import StatusTracker
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class ClaudeService:
14
+ """Wrapper for Claude API - supports both AMD LLM Gateway and public Anthropic API"""
15
+ def __init__(self):
16
+ amd_api_key = os.getenv("AMD_LLM_API_KEY")
17
+ anthropic_api_key = os.getenv("ANTHROPIC_API_KEY")
18
+
19
+ if amd_api_key and amd_api_key != "dummy":
20
+ # AMD LLM Gateway mode
21
+ base_url = os.getenv("ANTHROPIC_BASE_URL", "https://llm-api.amd.com/Anthropic")
22
+ self.client = Anthropic(
23
+ base_url=base_url,
24
+ api_key="dummy",
25
+ default_headers={
26
+ "Ocp-Apim-Subscription-Key": amd_api_key,
27
+ "user": os.getenv("USER", "unknown")
28
+ }
29
+ )
30
+ self.model = os.getenv("ANTHROPIC_DEFAULT_SONNET_MODEL", "claude-sonnet-4.5")
31
+ logger.info("Using AMD LLM Gateway")
32
+
33
+ elif anthropic_api_key:
34
+ # Public Anthropic API mode
35
+ base_url = os.getenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
36
+ self.client = Anthropic(
37
+ base_url=base_url,
38
+ api_key=anthropic_api_key
39
+ )
40
+ self.model = os.getenv("ANTHROPIC_DEFAULT_SONNET_MODEL", "claude-sonnet-4-20250514")
41
+ logger.info("Using public Anthropic API")
42
+
43
+ else:
44
+ raise ValueError(
45
+ "No API key found! Set either:\n"
46
+ " - AMD_LLM_API_KEY (for AMD internal users)\n"
47
+ " - ANTHROPIC_API_KEY (for public API users)"
48
+ )
49
+
50
+ # Merge all tools (file tools + graph tools)
51
+ self.tools = FILE_TOOLS + GRAPH_TOOLS
52
+
53
+ # Load project configuration and generate system prompt
54
+ config = get_config()
55
+ stats = config.stats
56
+
57
+ # System prompt with project context (auto-generated from metadata)
58
+ self.system_prompt = f""" CORE PRINCIPLE: THINK AHEAD, BATCH AGGRESSIVELY
59
+
60
+ Before calling ANY tools, think: "What will I need in the NEXT iteration? Fetch it ALL NOW!"
61
+
62
+ You are analyzing the {config.project_name} project.
63
+
64
+ **Project Context:**
65
+ - Source root: {config.metadata['paths']['source_root']}/ (all paths are relative to this)
66
+ - Files indexed: {stats['files_indexed']:,}
67
+ - Total symbols: {stats['total_symbols']:,}
68
+ - Call graph edges: {stats['edges']['calls']:,} CALLS relationships
69
+ - Include edges: {stats['edges']['includes']:,} INCLUDES relationships
70
+ - Field access edges: {stats['edges']['accesses']:,} ACCESSES relationships
71
+
72
+ **Path Convention:**
73
+ All file paths are relative to source root. Examples:
74
+ - 'firmware/main/mp1/src/app/power.c'
75
+ - 'firmware/main/mpccx/src/app/thermal.c'
76
+
77
+ **Available Tools:**
78
+
79
+ File System Tools:
80
+ - read_file(file_path): Read source code files (path relative to source root)
81
+ - list_directory(dir_path): Browse directory structure (path relative to source root)
82
+ - search_files(pattern, search_path): Find files by glob pattern
83
+
84
+ Semantic Graph Tools (use these to save tokens!):
85
+ - get_callers: Find what calls a function (1-hop backward)
86
+ - get_callees: Find what a function calls (1-hop forward)
87
+ - get_call_chain: Trace execution paths from A to B (multi-hop)
88
+ - search_symbols: Search for symbols by name pattern
89
+ - get_symbol_definition: Get ONLY one symbol's definition (not entire file)
90
+ - get_symbols_from_file: Get ALL symbols from a file (replaces read_file for headers)
91
+ - get_file_by_pattern: Find files by name pattern
92
+ - execute_sql: Custom SQL queries on the semantic graph
93
+
94
+ **Database Schema (for execute_sql):**
95
+
96
+ symbols table:
97
+ - id, name, type (function/struct/macro/variable/enum/typedef)
98
+ - file_path, line_number, signature
99
+ - scope_kind, scope_name (parent scope)
100
+
101
+ symbol_edges table:
102
+ - edge_type ('CALLS', 'INCLUDES', 'ACCESSES')
103
+ - src_symbol_id, dst_symbol_id (foreign keys to symbols.id)
104
+ - source_file, line_number (where edge occurs)
105
+
106
+ Example SQL:
107
+ SELECT s1.name as caller, s2.name as callee
108
+ FROM symbol_edges e
109
+ JOIN symbols s1 ON e.src_symbol_id = s1.id
110
+ JOIN symbols s2 ON e.dst_symbol_id = s2.id
111
+ WHERE e.edge_type = 'CALLS' AND s2.name = 'FunctionName'
112
+
113
+ WARN: WARN: WARN: CRITICAL: TARGET 3 ITERATIONS (4 iterations MAX) WARN: WARN: WARN:
114
+
115
+ **WHY 3 ITERATIONS?**
116
+ - Iterations 1-3 are CACHED (free to access later)
117
+ - Iteration 4+ is NOT CACHED (every tool result costs tokens)
118
+ - Solution: Get EVERYTHING in iterations 1-3, then answer in iteration 4
119
+
120
+ **THINK AHEAD! Predict what you'll need in future iterations and fetch it NOW!**
121
+
122
+ **MANDATORY ITERATION PLAN:**
123
+
124
+ **Iteration 1 (BROAD EXPLORATION - 15-25 tools):**
125
+ Think: "What are ALL the patterns, files, and areas I might need to explore?"
126
+ Then call EVERY exploration tool in ONE batch:
127
+ - search_symbols() with 5-10 different patterns ('%foo%', '%bar%', '%init%', '%process%', etc.)
128
+ - execute_sql() for 3-5 aggregate queries (file counts, symbol types, etc.)
129
+ - get_symbols_from_file() for 5-10 key files you predict will matter
130
+ - list_indexed_files() if exploring file structure
131
+ **THINK PREDICTIVELY:** If the question is "how does X work?", you'll need X's definition, callees, callers, related files - so search for ALL of those patterns NOW!
132
+
133
+ **Iteration 2 (FETCH EVERYTHING - 20-30 tools):**
134
+ Think: "From iteration 1, what are ALL the symbols/functions I found? I'll need ALL their details!"
135
+ Then fetch EVERYTHING in ONE batch:
136
+ - get_symbol_definition() for EVERY relevant symbol (15-25 symbols, not just 3-4!)
137
+ - get_callees() for EVERY function found
138
+ - get_callers() for EVERY function found
139
+ - execute_sql() for relationships between symbols
140
+ **BE GREEDY:** If iteration 1 found 20 symbols, fetch ALL 20 definitions NOW! Don't cherry-pick 5 and come back later!
141
+
142
+ **Iteration 3 (DEEP DIVE - 10-20 tools, LAST CACHED ITERATION!):**
143
+ Think: "What are ALL the remaining details I need to answer completely?"
144
+ WARN: THIS IS YOUR LAST CACHED ITERATION! Get EVERYTHING you need NOW!
145
+ - get_symbol_definition() with context_lines=20 for ALL core symbols
146
+ - get_call_chain() for ALL execution paths
147
+ - execute_sql() for ALL complex relationship queries
148
+ - get_symbols_from_file() with include_definitions=True for ALL critical files
149
+ **CRITICAL:** If you're missing ANYTHING, fetch it NOW! Iteration 4 is NOT cached - every tool wastes tokens!
150
+
151
+ **Iteration 4 (ANSWER - ZERO tools):**
152
+ Synthesize everything from iterations 1-3 into your complete answer.
153
+ WARN: DO NOT call tools in iteration 4 - they're not cached and waste tokens!
154
+ You have ALL the information from iterations 1-3 (cached). Use it to answer fully.
155
+
156
+ **Iterations 5-6 (EMERGENCY FALLBACK - SHOULD NOT REACH):**
157
+ You failed to complete in 4 iterations. Answer with what you have.
158
+
159
+ **EXAMPLES:**
160
+
161
+ PERFECT (4 iterations):
162
+ Q: "How does the indexer work?"
163
+ Iteration 1: [search_symbols('%index%'), search_symbols('%parse%'), search_symbols('%ctags%'),
164
+ execute_sql("SELECT * FROM symbols WHERE name LIKE '%index%'"),
165
+ execute_sql("SELECT * FROM symbols WHERE type='class'"),
166
+ get_symbols_from_file('indexer/indexer.py'),
167
+ get_symbols_from_file('indexer/ctags_parser.py'),
168
+ ... 40 more tools] (50 tools total)
169
+ Iteration 2: [get_symbol_definition('Indexer'), get_symbol_definition('parse_symbols'),
170
+ get_callees('index_directory'), get_callers('parse_symbols'),
171
+ ... 45 more tools] (60 tools total)
172
+ Iteration 3: [execute_sql("SELECT * FROM symbol_edges WHERE src_symbol_id=123"),
173
+ get_call_chain('main', 'parse_symbols'), ... 15 more tools] (20 tools total)
174
+ Iteration 4: "The indexer works in 3 stages..." (ANSWER, 0 tools)
175
+
176
+ ERROR: FAILURE (6+ iterations):
177
+ Iteration 1: 5 tools
178
+ Iteration 2: 3 tools
179
+ Iteration 3: 4 tools
180
+ ... YOU FAILED. Start over and batch properly!
181
+
182
+ **If you call fewer than 20 tools in iterations 1-2, you are doing it WRONG.**
183
+
184
+ **IMPORTANT: NEVER mention iterations, caching, or your tool-gathering strategy in your final answer.**
185
+ The user doesn't need to know about your internal process. Just answer their question directly and professionally.
186
+ """
187
+
188
+ def _truncate_conversation_history(self, conversation_history, max_messages=10):
189
+ """Keep last N messages for cache efficiency"""
190
+ if len(conversation_history) > max_messages:
191
+ return conversation_history[-max_messages:]
192
+ return conversation_history
193
+
194
+ def _calculate_savings(self, user_input_tokens, files_accessed_count):
195
+ """
196
+ Calculate token savings vs traditional manual approach
197
+
198
+ Traditional: User pastes N files × avg_lines × tokens_per_line
199
+ srcodex: User types short queries, tools fetch only needed data
200
+
201
+ Returns: (traditional_tokens, savings_percentage)
202
+ """
203
+ config = get_config()
204
+
205
+ # Get average file size from metadata
206
+ avg_lines_per_file = config.stats.get('avg_lines_per_file', 500)
207
+ tokens_per_line = 4 # Industry standard
208
+
209
+ # Traditional approach: paste entire files
210
+ traditional_tokens = files_accessed_count * avg_lines_per_file * tokens_per_line
211
+
212
+ # Avoid division by zero
213
+ if traditional_tokens == 0:
214
+ return 0, 0.0
215
+
216
+ # Calculate savings percentage
217
+ savings_percentage = (1 - user_input_tokens / traditional_tokens) * 100
218
+
219
+ # Cap at 99.9% (avoid showing 100%)
220
+ savings_percentage = min(savings_percentage, 99.9)
221
+
222
+ return traditional_tokens, savings_percentage
223
+
224
+ def send_message(self, message):
225
+ """Send Message to Claude and get response"""
226
+
227
+ response = self.client.messages.create(
228
+ model=self.model,
229
+ max_tokens=8192,
230
+ system=self.system_prompt,
231
+ messages=[
232
+ {"role": "user", "content": message}
233
+ ]
234
+ )
235
+
236
+ for block in response.content:
237
+ if block.type == "text":
238
+ return block.text
239
+ return ""
240
+
241
+ def send_message_with_tools(self, message, conversation_history=None):
242
+ """Send message to Claude with tool support"""
243
+ logger.info("=" * 80)
244
+ logger.info(f"MSG: User message: {message}")
245
+
246
+ # Build messages array with conversation history
247
+ if conversation_history:
248
+ messages = self._truncate_conversation_history(conversation_history)
249
+ logger.info(f"HISTORY: Using conversation history ({len(conversation_history)} messages, truncated to {len(messages)})")
250
+ else:
251
+ messages = []
252
+
253
+ # Add current message
254
+ messages.append({"role": "user", "content": message})
255
+
256
+ # Token tracking
257
+ total_input_tokens = 0
258
+ total_output_tokens = 0
259
+
260
+ # File access tracking for savings calculation
261
+ files_accessed = set()
262
+
263
+ # Cache breakpoint tracking (max 4 total: 1 for system + 3 for messages)
264
+ cache_breakpoints_used = 0
265
+ max_cache_breakpoints = 3
266
+
267
+ # Tool use loop - max 6 iterations (target: 4, absolute emergency: 5)
268
+ iteration = 0
269
+ max_iterations = 6
270
+ while True:
271
+ iteration += 1
272
+
273
+ # At iteration 6, FORCE final answer (disable tools completely)
274
+ if iteration > max_iterations:
275
+ logger.error(f"CRITICAL: ITERATION {iteration} - EXCEEDED MAX! Forcing answer with available context.")
276
+
277
+ # Warnings for iterations past target
278
+ if iteration == 5:
279
+ logger.warning("WARN: ITERATION 5/6 - Should have finished in 4! One more iteration left.")
280
+ elif iteration == 6:
281
+ logger.error("CRITICAL: ITERATION 6/6 - FINAL ITERATION! Must answer NOW.")
282
+
283
+ logger.info(f"\nITER Iteration {iteration}/6: Calling Claude API...")
284
+
285
+ # Build system prompt with cache control
286
+ system_with_cache = [
287
+ {
288
+ "type": "text",
289
+ "text": self.system_prompt,
290
+ "cache_control": {"type": "ephemeral"}
291
+ }
292
+ ]
293
+
294
+ # Keep tools constant to preserve cache
295
+ tools_to_use = self.tools
296
+
297
+ # At iteration 6, inject urgent message to FORCE answer without tools
298
+ messages_to_send = messages
299
+ if iteration >= 6:
300
+ # Add urgent instruction as last message
301
+ messages_to_send = messages + [{
302
+ "role": "user",
303
+ "content": "WARN: CRITICAL: This is iteration 6/6. You MUST provide your final answer NOW using everything you've gathered. DO NOT call any more tools. Synthesize your findings and answer the user's question completely."
304
+ }]
305
+
306
+ try:
307
+ response = self.client.messages.create(
308
+ model=self.model,
309
+ max_tokens=8192,
310
+ system=system_with_cache,
311
+ tools=tools_to_use,
312
+ messages=messages_to_send,
313
+ extra_headers={
314
+ "anthropic-beta": "context-management-2025-06-27,prompt-caching-2024-07-31"
315
+ }
316
+ )
317
+ except APIStatusError as e:
318
+ logger.error(f"ERROR: API Error: {e.status_code} {e.message}")
319
+ logger.error(f" Response body: {e.body}")
320
+ logger.error(f" Request details:")
321
+ logger.error(f" - Model: {self.model}")
322
+ logger.error(f" - Messages count: {len(messages)}")
323
+ logger.error(f" - Tools count: {len(self.tools)}")
324
+ if messages:
325
+ logger.error(f" - Last message: {messages[-1]}")
326
+ raise
327
+ except APIError as e:
328
+ logger.error(f"ERROR: API Error: {e}")
329
+ raise
330
+
331
+ # Check stop reason
332
+ if response.stop_reason == "end_turn":
333
+ # No more tool calls, return final text
334
+ logger.info(" Claude finished (no more tools)")
335
+ for block in response.content:
336
+ if block.type == "text":
337
+ logger.info(f" Response length: {len(block.text)} chars")
338
+ logger.info("=" * 80)
339
+ return block.text
340
+ return ""
341
+
342
+ elif response.stop_reason == "tool_use":
343
+ # Block tools after iteration 3 (cache is full)
344
+ if iteration > 3:
345
+ logger.error(f"BLOCKED: BLOCKED: Claude tried to call {sum(1 for b in response.content if b.type == 'tool_use')} tools in iteration {iteration}!")
346
+ logger.error(" Tools are ONLY allowed in iterations 1-3 (cached). Forcing answer with cached data.")
347
+
348
+ # Skip appending assistant message with tool_use to avoid API error
349
+ # Inject user message to force answer
350
+ messages.append({
351
+ "role": "user",
352
+ "content": "BLOCKED: TOOL CALLS BLOCKED! You are in iteration 4+. Tools are ONLY allowed in iterations 1-3. You have ALL the data from cached iterations. Provide your complete answer NOW. DO NOT call any more tools."
353
+ })
354
+ # Loop back to get answer
355
+ continue
356
+
357
+ # Claude wants to use tools
358
+ logger.info(" Claude is using tools...")
359
+
360
+ # Add assistant's response to messages
361
+ messages.append({
362
+ "role": "assistant",
363
+ "content": response.content
364
+ })
365
+
366
+ # Execute all tool calls
367
+ tool_results = []
368
+ tool_count = 0
369
+ for block in response.content:
370
+ if block.type == "tool_use":
371
+ tool_count += 1
372
+ logger.info(f"\n Tool Tool #{tool_count}: {block.name}")
373
+ logger.info(f" Input: {block.input}")
374
+
375
+ # Route to correct tool handler
376
+ file_tools = ["read_file", "list_directory", "search_files"]
377
+ graph_tools = ["get_callers", "get_callees", "get_call_chain", "execute_sql",
378
+ "get_file_by_pattern", "get_file_info", "list_indexed_files",
379
+ "search_symbols", "get_symbol_definition", "get_symbols_from_file"]
380
+
381
+ if block.name in file_tools:
382
+ logger.info(f" Type: FILE SYSTEM TOOL")
383
+ result = execute_file_tool(block.name, block.input)
384
+ elif block.name in graph_tools:
385
+ logger.info(f" Type: GRAPH TOOL ")
386
+ result = execute_graph_tool(block.name, block.input)
387
+ else:
388
+ logger.warning(f" Type: UNKNOWN TOOL!")
389
+ result = {"error": f"Unknown tool: {block.name}"}
390
+
391
+ # Track files accessed for savings calculation
392
+ if block.name in ["read_file", "get_symbols_from_file", "get_file_info"]:
393
+ file_path = block.input.get("file_path")
394
+ if file_path:
395
+ files_accessed.add(file_path)
396
+
397
+ # Log result summary
398
+ if isinstance(result, dict):
399
+ if "error" in result:
400
+ logger.error(f" ERROR: Error: {result['error']}")
401
+ elif "count" in result:
402
+ logger.info(f" Returned {result['count']} results")
403
+ else:
404
+ logger.info(f" Success (keys: {list(result.keys())})")
405
+
406
+ # Add tool result (no truncation - iterations 1-3 are cached)
407
+ tool_results.append({
408
+ "type": "tool_result",
409
+ "tool_use_id": block.id,
410
+ "content": str(result)
411
+ })
412
+
413
+ logger.info(f"\n Executed {tool_count} tool(s), sending results back to Claude...")
414
+
415
+ # Send tool results back to Claude
416
+ # Cache tool results if under breakpoint limit (max 4 total: system + 3 messages)
417
+ if tool_results and cache_breakpoints_used < max_cache_breakpoints:
418
+ tool_results[-1]["cache_control"] = {"type": "ephemeral"}
419
+ cache_breakpoints_used += 1
420
+ logger.info(f"CACHE: Cache breakpoint set on last tool result (iteration {iteration}, breakpoint {cache_breakpoints_used}/{max_cache_breakpoints})")
421
+ elif tool_results and cache_breakpoints_used >= max_cache_breakpoints:
422
+ logger.info(f"WARN: Skipping cache (already at {cache_breakpoints_used}/{max_cache_breakpoints} breakpoints) - rely on parallel tools to finish quickly!")
423
+
424
+ messages.append({
425
+ "role": "user",
426
+ "content": tool_results
427
+ })
428
+
429
+ # Continue loop to get Claude's response
430
+
431
+ else:
432
+ # Unexpected stop reason
433
+ return f"Unexpected stop reason: {response.stop_reason}"
434
+
435
+ def stream_message_with_tools(self, message, conversation_history=None):
436
+ """
437
+ Stream message to Claude with tool support - yields text chunks and metadata
438
+
439
+ Args:
440
+ message: User's current message
441
+ conversation_history: Optional list of previous messages [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]
442
+
443
+ Yields:
444
+ dict: Either text chunks or token metadata
445
+ {"type": "text", "content": "..."}
446
+ {"type": "tokens", "input": 1234, "output": 56, "total": 1290, "cache_read": 100, "cache_write": 50}
447
+ """
448
+ logger.info("=" * 80)
449
+ logger.info(f"MSG: User message (streaming): {message}")
450
+
451
+ # Initialize status tracker
452
+ status = StatusTracker()
453
+ status.start_query()
454
+
455
+ # Build messages array with conversation history
456
+ if conversation_history:
457
+ messages = self._truncate_conversation_history(conversation_history)
458
+ logger.info(f"HISTORY: Using conversation history ({len(conversation_history)} messages, truncated to {len(messages)})")
459
+ else:
460
+ messages = []
461
+
462
+ # Estimate conversation history tokens BEFORE adding current message
463
+ # Each previous message ~10-50 tokens (use 20 as conservative estimate to avoid going negative)
464
+ conversation_history_tokens = len(messages) * 20
465
+
466
+ # NOTE: Conversation history caching disabled to stay within 4 cache breakpoint limit
467
+ # We use: 1=system, 2=iter1 tools, 3=iter2 tools, 4=iter3 tools
468
+
469
+ # Add current message
470
+ messages.append({"role": "user", "content": message})
471
+
472
+ # Token tracking
473
+ total_input_tokens = 0
474
+ total_output_tokens = 0
475
+ total_cache_read_tokens = 0
476
+ total_cache_write_tokens = 0
477
+ user_message_tokens = 0
478
+ iteration_1_cache_write = 0 # Track iteration 1 cache (system + tools)
479
+ cached_iterations_input = 0 # Track input tokens from iterations 1-3 (when we're caching)
480
+ PROMPT_OVERHEAD = 300 # System prompt tokens (constant across queries)
481
+
482
+ # File access tracking for savings calculation
483
+ files_accessed = set()
484
+
485
+ # Cache breakpoint tracking (max 4 total: 1 for system + 3 for messages)
486
+ cache_breakpoints_used = 0
487
+ max_cache_breakpoints = 3
488
+
489
+ # Tool use loop - max 6 iterations (target: 4)
490
+ iteration = 0
491
+ max_iterations = 6
492
+ while True:
493
+ iteration += 1
494
+ status.start_iteration(iteration)
495
+
496
+ # At iteration 6, FORCE final answer (disable tools completely)
497
+ if iteration > max_iterations:
498
+ logger.error(f"CRITICAL: ITERATION {iteration} - EXCEEDED MAX! Forcing answer with available context.")
499
+
500
+ # Warnings for iterations past target
501
+ if iteration == 5:
502
+ logger.warning("WARN: ITERATION 5/6 - Should have finished in 4! One more iteration left.")
503
+ elif iteration == 6:
504
+ logger.error("CRITICAL: ITERATION 6/6 - FINAL ITERATION! Must answer NOW or fail.")
505
+
506
+ logger.info(f"\nITER Iteration {iteration}/6: Calling Claude API...")
507
+
508
+ # Build system prompt with cache control
509
+ system_with_cache = [
510
+ {
511
+ "type": "text",
512
+ "text": self.system_prompt,
513
+ "cache_control": {"type": "ephemeral"}
514
+ }
515
+ ]
516
+
517
+ # Keep tools constant to preserve cache
518
+ tools_with_cache = self.tools
519
+
520
+ # At iteration 6, inject urgent message to FORCE answer without tools
521
+ messages_to_send = messages
522
+ if iteration >= 6:
523
+ # Add urgent instruction as last message (doesn't break cache since it's a NEW iteration)
524
+ messages_to_send = messages + [{
525
+ "role": "user",
526
+ "content": "WARN: CRITICAL: This is iteration 6/6. You MUST provide your final answer NOW using everything you've gathered. DO NOT call any more tools. Synthesize your findings and answer the user's question completely."
527
+ }]
528
+
529
+ try:
530
+ response = self.client.messages.create(
531
+ model=self.model,
532
+ max_tokens=8192,
533
+ system=system_with_cache,
534
+ tools=tools_with_cache,
535
+ messages=messages_to_send
536
+ )
537
+ except APIStatusError as e:
538
+ logger.error(f"ERROR: API Error: {e.status_code} {e.message}")
539
+ logger.error(f" Response body: {e.body}")
540
+ logger.error(f" Request details:")
541
+ logger.error(f" - Model: {self.model}")
542
+ logger.error(f" - Messages count: {len(messages)}")
543
+ logger.error(f" - Tools count: {len(tools_with_cache)}")
544
+ if messages:
545
+ logger.error(f" - Last message: {messages[-1]}")
546
+ # Yield error to frontend
547
+ yield {"type": "error", "content": f"API Error {e.status_code}: {e.message}"}
548
+ return
549
+ except APIError as e:
550
+ logger.error(f"ERROR: API Error: {e}")
551
+ yield {"type": "error", "content": f"API Error: {str(e)}"}
552
+ return
553
+
554
+ # Track tokens
555
+ cache_read = getattr(response.usage, 'cache_read_input_tokens', 0)
556
+ cache_write = getattr(response.usage, 'cache_creation_input_tokens', 0)
557
+
558
+ total_input_tokens += response.usage.input_tokens
559
+ total_output_tokens += response.usage.output_tokens
560
+ total_cache_read_tokens += cache_read
561
+ total_cache_write_tokens += cache_write
562
+
563
+ # Track input tokens from cached iterations (1-3)
564
+ if iteration <= 3:
565
+ cached_iterations_input += response.usage.input_tokens
566
+
567
+ # Calculate user message tokens in iteration 1
568
+ # Iteration 1: input = user_message + system_prompt + tools
569
+ # cache_write = system_prompt + tools (if caching)
570
+ # user_message = input - cache_write
571
+ if iteration == 1:
572
+ if cache_write > 0:
573
+ # Session with existing cache: input includes cache write
574
+ user_message_tokens = response.usage.input_tokens - cache_write
575
+ iteration_1_cache_write = cache_write
576
+ else:
577
+ # First ever query (no cache): all input is user message + system + tools
578
+ # We don't cache on first query, so total_input IS the cost
579
+ user_message_tokens = response.usage.input_tokens
580
+ logger.info(f" User message (+ system/tools if no cache): ~{user_message_tokens} tokens")
581
+
582
+ logger.info(f" FILES: Tokens: {response.usage.input_tokens} in / {response.usage.output_tokens} out")
583
+ if cache_read > 0 or cache_write > 0:
584
+ logger.info(f" 💾 Cache: {cache_read} read / {cache_write} write")
585
+
586
+ # Check stop reason
587
+ if response.stop_reason == "end_turn":
588
+ # No tools used, stream the final text
589
+ logger.info("Claude finished (no more tools)")
590
+
591
+ # Update status to "Preparing answer..." if this is iteration 5 or final iteration
592
+ if iteration >= 4:
593
+ status.set_preparing_answer()
594
+ yield status.get_status_message()
595
+
596
+ for block in response.content:
597
+ if block.type == "text":
598
+ logger.info(f"Streaming response ({len(block.text)} chars)")
599
+ # Yield text chunks
600
+ for char in block.text:
601
+ yield {"type": "text", "content": char}
602
+
603
+ # Calculate token savings
604
+ # User input = all input tokens from iterations 1-3 minus overhead
605
+ # Overhead = system prompt + conversation history
606
+ # Example: 3349 input - 300 prompt - 1050 history = 1999 tokens
607
+ user_input_only = max(0, cached_iterations_input - PROMPT_OVERHEAD - conversation_history_tokens)
608
+ traditional_equiv, new_savings_pct = self._calculate_savings(user_input_only, len(files_accessed))
609
+
610
+ # If no files accessed, keep previous savings percentage (don't reset to 0%)
611
+ if len(files_accessed) == 0 and hasattr(self, 'last_savings_pct'):
612
+ savings_pct = self.last_savings_pct
613
+ else:
614
+ savings_pct = new_savings_pct
615
+ self.last_savings_pct = new_savings_pct # Save for next time
616
+
617
+ # Yield final token count
618
+ total_tokens = total_input_tokens + total_output_tokens
619
+ logger.info(f"\nTOTAL: {total_input_tokens} input, {total_output_tokens} output, {total_cache_read_tokens} cache read, {total_cache_write_tokens} cache write (total {total_tokens})")
620
+ logger.info(f"FILES: {len(files_accessed)} accessed, traditional: {traditional_equiv} tokens, savings: {savings_pct:.1f}%")
621
+ logger.info("=" * 80)
622
+
623
+ # Mark query as complete
624
+ status.set_complete()
625
+ yield status.get_status_message()
626
+
627
+ # End status tracking
628
+ status.end_query()
629
+
630
+ yield {
631
+ "type": "tokens",
632
+ "input": total_input_tokens,
633
+ "output": total_output_tokens,
634
+ "total": total_tokens,
635
+ "cache_read": total_cache_read_tokens,
636
+ "cache_write": total_cache_write_tokens,
637
+ "user_input_only": user_input_only,
638
+ "files_accessed": len(files_accessed),
639
+ "traditional_equivalent": traditional_equiv,
640
+ "savings_percentage": savings_pct
641
+ }
642
+ return
643
+
644
+ elif response.stop_reason == "tool_use":
645
+ # Block tools after iteration 3 (cache is full)
646
+ if iteration > 3:
647
+ logger.error(f"BLOCKED: BLOCKED: Claude tried to call {sum(1 for b in response.content if b.type == 'tool_use')} tools in iteration {iteration}!")
648
+ logger.error(" Tools are ONLY allowed in iterations 1-3 (cached). Forcing answer with cached data.")
649
+
650
+ # Skip appending assistant message with tool_use to avoid API error
651
+ # Inject user message to force answer
652
+ messages.append({
653
+ "role": "user",
654
+ "content": "BLOCKED: TOOL CALLS BLOCKED! You are in iteration 4+. Tools are ONLY allowed in iterations 1-3. You have ALL the data from cached iterations. Provide your complete answer NOW. DO NOT call any more tools."
655
+ })
656
+ # Loop back to get answer
657
+ continue
658
+
659
+ # Claude wants to use tools
660
+ logger.info(" Claude is using tools...")
661
+
662
+ # Add assistant's response to messages
663
+ messages.append({
664
+ "role": "assistant",
665
+ "content": response.content
666
+ })
667
+
668
+ # Count total tools first
669
+ total_tools = sum(1 for block in response.content if block.type == "tool_use")
670
+
671
+ # Execute all tool calls (don't stream this part)
672
+ tool_results = []
673
+ tool_count = 0
674
+ first_tool_name = None
675
+ for block in response.content:
676
+ if block.type == "tool_use":
677
+ tool_count += 1
678
+ logger.info(f"\n Tool Tool #{tool_count}: {block.name}")
679
+ logger.info(f" Input: {block.input}")
680
+
681
+ # Capture first tool name for status
682
+ if tool_count == 1:
683
+ first_tool_name = block.name
684
+ # Update status and yield it
685
+ status.set_tool_status(first_tool_name, total_tools)
686
+ yield status.get_status_message()
687
+
688
+ # Route to correct tool handler
689
+ file_tools = ["read_file", "list_directory", "search_files"]
690
+ graph_tools = ["get_callers", "get_callees", "get_call_chain", "execute_sql",
691
+ "get_file_by_pattern", "get_file_info", "list_indexed_files",
692
+ "search_symbols", "get_symbol_definition", "get_symbols_from_file"]
693
+
694
+ if block.name in file_tools:
695
+ logger.info(f" Type: FILE SYSTEM TOOL")
696
+ result = execute_file_tool(block.name, block.input)
697
+ elif block.name in graph_tools:
698
+ logger.info(f" Type: GRAPH TOOL ")
699
+ result = execute_graph_tool(block.name, block.input)
700
+ else:
701
+ logger.warning(f" Type: UNKNOWN TOOL!")
702
+ result = {"error": f"Unknown tool: {block.name}"}
703
+
704
+ # Track files accessed for savings calculation
705
+ if block.name in ["read_file", "get_symbols_from_file", "get_file_info"]:
706
+ file_path = block.input.get("file_path")
707
+ if file_path:
708
+ files_accessed.add(file_path)
709
+
710
+ # Log result summary
711
+ if isinstance(result, dict):
712
+ if "error" in result:
713
+ logger.error(f" Error: {result['error']}")
714
+ elif "count" in result:
715
+ logger.info(f" Returned {result['count']} results")
716
+ else:
717
+ logger.info(f" Success (keys: {list(result.keys())})")
718
+
719
+ # Add tool result (no truncation - iterations 1-3 are cached)
720
+ tool_results.append({
721
+ "type": "tool_result",
722
+ "tool_use_id": block.id,
723
+ "content": str(result)
724
+ })
725
+
726
+ logger.info(f"\n Executed {tool_count} tool(s), sending results back to Claude...")
727
+
728
+ # Send tool results back to Claude
729
+ # Cache tool results if under breakpoint limit (max 4 total: system + 3 messages)
730
+ if tool_results and cache_breakpoints_used < max_cache_breakpoints:
731
+ tool_results[-1]["cache_control"] = {"type": "ephemeral"}
732
+ cache_breakpoints_used += 1
733
+ logger.info(f"CACHE: Cache breakpoint set on last tool result (iteration {iteration}, breakpoint {cache_breakpoints_used}/{max_cache_breakpoints})")
734
+ elif tool_results and cache_breakpoints_used >= max_cache_breakpoints:
735
+ logger.info(f"WARN: Skipping cache (already at {cache_breakpoints_used}/{max_cache_breakpoints} breakpoints) - rely on parallel tools to finish quickly!")
736
+
737
+ messages.append({
738
+ "role": "user",
739
+ "content": tool_results
740
+ })
741
+
742
+ else:
743
+ yield {"type": "text", "content": f"Unexpected stop reason: {response.stop_reason}"}
744
+ return
745
+
746
+ def stream_message(self, message):
747
+ """Stream message to Claude and yield text chunks"""
748
+ with self.client.messages.stream(
749
+ model=self.model,
750
+ max_tokens=16000,
751
+ messages=[{"role": "user", "content": message}]
752
+ ) as stream:
753
+ for text in stream.text_stream:
754
+ yield text