dtSpark 1.0.4__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 (96) hide show
  1. dtSpark/__init__.py +0 -0
  2. dtSpark/_description.txt +1 -0
  3. dtSpark/_full_name.txt +1 -0
  4. dtSpark/_licence.txt +21 -0
  5. dtSpark/_metadata.yaml +6 -0
  6. dtSpark/_name.txt +1 -0
  7. dtSpark/_version.txt +1 -0
  8. dtSpark/aws/__init__.py +7 -0
  9. dtSpark/aws/authentication.py +296 -0
  10. dtSpark/aws/bedrock.py +578 -0
  11. dtSpark/aws/costs.py +318 -0
  12. dtSpark/aws/pricing.py +580 -0
  13. dtSpark/cli_interface.py +2645 -0
  14. dtSpark/conversation_manager.py +3050 -0
  15. dtSpark/core/__init__.py +12 -0
  16. dtSpark/core/application.py +3355 -0
  17. dtSpark/core/context_compaction.py +735 -0
  18. dtSpark/daemon/__init__.py +104 -0
  19. dtSpark/daemon/__main__.py +10 -0
  20. dtSpark/daemon/action_monitor.py +213 -0
  21. dtSpark/daemon/daemon_app.py +730 -0
  22. dtSpark/daemon/daemon_manager.py +289 -0
  23. dtSpark/daemon/execution_coordinator.py +194 -0
  24. dtSpark/daemon/pid_file.py +169 -0
  25. dtSpark/database/__init__.py +482 -0
  26. dtSpark/database/autonomous_actions.py +1191 -0
  27. dtSpark/database/backends.py +329 -0
  28. dtSpark/database/connection.py +122 -0
  29. dtSpark/database/conversations.py +520 -0
  30. dtSpark/database/credential_prompt.py +218 -0
  31. dtSpark/database/files.py +205 -0
  32. dtSpark/database/mcp_ops.py +355 -0
  33. dtSpark/database/messages.py +161 -0
  34. dtSpark/database/schema.py +673 -0
  35. dtSpark/database/tool_permissions.py +186 -0
  36. dtSpark/database/usage.py +167 -0
  37. dtSpark/files/__init__.py +4 -0
  38. dtSpark/files/manager.py +322 -0
  39. dtSpark/launch.py +39 -0
  40. dtSpark/limits/__init__.py +10 -0
  41. dtSpark/limits/costs.py +296 -0
  42. dtSpark/limits/tokens.py +342 -0
  43. dtSpark/llm/__init__.py +17 -0
  44. dtSpark/llm/anthropic_direct.py +446 -0
  45. dtSpark/llm/base.py +146 -0
  46. dtSpark/llm/context_limits.py +438 -0
  47. dtSpark/llm/manager.py +177 -0
  48. dtSpark/llm/ollama.py +578 -0
  49. dtSpark/mcp_integration/__init__.py +5 -0
  50. dtSpark/mcp_integration/manager.py +653 -0
  51. dtSpark/mcp_integration/tool_selector.py +225 -0
  52. dtSpark/resources/config.yaml.template +631 -0
  53. dtSpark/safety/__init__.py +22 -0
  54. dtSpark/safety/llm_service.py +111 -0
  55. dtSpark/safety/patterns.py +229 -0
  56. dtSpark/safety/prompt_inspector.py +442 -0
  57. dtSpark/safety/violation_logger.py +346 -0
  58. dtSpark/scheduler/__init__.py +20 -0
  59. dtSpark/scheduler/creation_tools.py +599 -0
  60. dtSpark/scheduler/execution_queue.py +159 -0
  61. dtSpark/scheduler/executor.py +1152 -0
  62. dtSpark/scheduler/manager.py +395 -0
  63. dtSpark/tools/__init__.py +4 -0
  64. dtSpark/tools/builtin.py +833 -0
  65. dtSpark/web/__init__.py +20 -0
  66. dtSpark/web/auth.py +152 -0
  67. dtSpark/web/dependencies.py +37 -0
  68. dtSpark/web/endpoints/__init__.py +17 -0
  69. dtSpark/web/endpoints/autonomous_actions.py +1125 -0
  70. dtSpark/web/endpoints/chat.py +621 -0
  71. dtSpark/web/endpoints/conversations.py +353 -0
  72. dtSpark/web/endpoints/main_menu.py +547 -0
  73. dtSpark/web/endpoints/streaming.py +421 -0
  74. dtSpark/web/server.py +578 -0
  75. dtSpark/web/session.py +167 -0
  76. dtSpark/web/ssl_utils.py +195 -0
  77. dtSpark/web/static/css/dark-theme.css +427 -0
  78. dtSpark/web/static/js/actions.js +1101 -0
  79. dtSpark/web/static/js/chat.js +614 -0
  80. dtSpark/web/static/js/main.js +496 -0
  81. dtSpark/web/static/js/sse-client.js +242 -0
  82. dtSpark/web/templates/actions.html +408 -0
  83. dtSpark/web/templates/base.html +93 -0
  84. dtSpark/web/templates/chat.html +814 -0
  85. dtSpark/web/templates/conversations.html +350 -0
  86. dtSpark/web/templates/goodbye.html +81 -0
  87. dtSpark/web/templates/login.html +90 -0
  88. dtSpark/web/templates/main_menu.html +983 -0
  89. dtSpark/web/templates/new_conversation.html +191 -0
  90. dtSpark/web/web_interface.py +137 -0
  91. dtspark-1.0.4.dist-info/METADATA +187 -0
  92. dtspark-1.0.4.dist-info/RECORD +96 -0
  93. dtspark-1.0.4.dist-info/WHEEL +5 -0
  94. dtspark-1.0.4.dist-info/entry_points.txt +3 -0
  95. dtspark-1.0.4.dist-info/licenses/LICENSE +21 -0
  96. dtspark-1.0.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3050 @@
1
+ """
2
+ Conversation manager module for handling chat sessions with intelligent context compaction.
3
+
4
+ This module provides functionality for:
5
+ - Managing conversation state and history
6
+ - Intelligent context compaction using model-specific context windows
7
+ - Message history management with selective preservation
8
+ - MCP tool integration
9
+ """
10
+
11
+ import logging
12
+ import asyncio
13
+ import json
14
+ import concurrent.futures
15
+ from typing import List, Dict, Optional, Tuple, Any, Union
16
+ from datetime import datetime
17
+ from dtSpark.tools import builtin
18
+ from dtSpark.mcp_integration import ToolSelector
19
+ from dtSpark.limits import LimitStatus
20
+ from dtSpark.safety import PromptInspector
21
+ from dtSpark.database.tool_permissions import PERMISSION_ALLOWED, PERMISSION_DENIED
22
+ from dtSpark.llm.context_limits import ContextLimitResolver
23
+ from dtSpark.core.context_compaction import ContextCompactor, get_provider_from_model_id
24
+
25
+
26
+ class ConversationManager:
27
+ """Manages conversation state and automatic rollup for token management."""
28
+
29
+ def __init__(self, database, bedrock_service, max_tokens: int = 4096,
30
+ rollup_threshold: float = 0.8, rollup_summary_ratio: float = 0.3,
31
+ max_tool_result_tokens: int = 10000, max_tool_iterations: int = 25,
32
+ max_tool_selections: int = 30, emergency_rollup_threshold: float = 0.95,
33
+ mcp_manager = None, cli_interface = None, web_interface = None,
34
+ global_instructions: Optional[str] = None,
35
+ token_manager = None, prompt_inspector: Optional[PromptInspector] = None,
36
+ user_guid: Optional[str] = None, config: Optional[Dict[str, Any]] = None):
37
+ """
38
+ Initialise the conversation manager.
39
+
40
+ Args:
41
+ database: ConversationDatabase instance
42
+ bedrock_service: BedrockService instance
43
+ max_tokens: Maximum token limit for the model
44
+ rollup_threshold: Fraction of max_tokens at which to trigger rollup (0.0-1.0)
45
+ rollup_summary_ratio: Target ratio for summarised content (0.0-1.0)
46
+ max_tool_result_tokens: Maximum tokens per tool result (prevents context overflow)
47
+ max_tool_iterations: Maximum consecutive tool calls before stopping
48
+ max_tool_selections: Maximum number of tools to send with each request
49
+ emergency_rollup_threshold: Force rollup threshold even during tool use (0.0-1.0)
50
+ mcp_manager: Optional MCPManager instance for tool support
51
+ cli_interface: Optional CLI interface for displaying tool calls
52
+ web_interface: Optional Web interface for tool permission prompts
53
+ global_instructions: Optional global instructions that apply to all conversations
54
+ token_manager: Optional TokenManager instance for usage limit enforcement
55
+ prompt_inspector: Optional PromptInspector for security analysis
56
+ user_guid: Optional user GUID for multi-user support
57
+ config: Optional configuration dictionary for embedded tools
58
+ """
59
+ self.database = database
60
+ self.bedrock_service = bedrock_service
61
+ self.default_max_tokens = max_tokens # Store global default
62
+ self.max_tokens = max_tokens # Current max_tokens (can be overridden per-conversation)
63
+ self.rollup_threshold = rollup_threshold
64
+ self.rollup_summary_ratio = rollup_summary_ratio
65
+ self.max_tool_result_tokens = max_tool_result_tokens
66
+ self.max_tool_iterations = max_tool_iterations
67
+ self.max_tool_selections = max_tool_selections
68
+ self.emergency_rollup_threshold = emergency_rollup_threshold
69
+ self.current_conversation_id = None
70
+ self.current_instructions: Optional[str] = None
71
+ self.global_instructions: Optional[str] = global_instructions
72
+ self.mcp_manager = mcp_manager
73
+ self.cli_interface = cli_interface
74
+ self.web_interface = web_interface
75
+ self.token_manager = token_manager
76
+ self.prompt_inspector = prompt_inspector
77
+ self.user_guid = user_guid
78
+ self.config = config # Store config for embedded tools
79
+ self._tools_cache: Optional[List[Dict[str, Any]]] = None
80
+ self._in_tool_use_loop = False # Flag to defer rollup during tool use sequences
81
+ # Initialise tool selector for intelligent tool selection
82
+ self.tool_selector = ToolSelector(max_tools_per_request=max_tool_selections)
83
+
84
+ # Initialise intelligent context compaction system
85
+ self.context_limit_resolver = ContextLimitResolver(config)
86
+ self.context_compactor = ContextCompactor(
87
+ bedrock_service=bedrock_service,
88
+ database=database,
89
+ context_limit_resolver=self.context_limit_resolver,
90
+ cli_interface=cli_interface,
91
+ web_interface=web_interface,
92
+ compaction_threshold=rollup_threshold,
93
+ emergency_threshold=emergency_rollup_threshold,
94
+ compaction_ratio=rollup_summary_ratio
95
+ )
96
+ logging.info("ConversationManager initialised with intelligent context compaction")
97
+
98
+ def update_service(self, bedrock_service):
99
+ """
100
+ Update the LLM service used for conversation and compaction.
101
+
102
+ This should be called when the active provider/model changes.
103
+
104
+ Args:
105
+ bedrock_service: The new LLM service to use
106
+ """
107
+ old_provider = "unknown"
108
+ new_provider = "unknown"
109
+
110
+ if self.bedrock_service and hasattr(self.bedrock_service, 'get_provider_name'):
111
+ old_provider = self.bedrock_service.get_provider_name()
112
+ if bedrock_service and hasattr(bedrock_service, 'get_provider_name'):
113
+ new_provider = bedrock_service.get_provider_name()
114
+
115
+ self.bedrock_service = bedrock_service
116
+
117
+ # Also update the context compactor's service
118
+ if hasattr(self, 'context_compactor') and self.context_compactor:
119
+ self.context_compactor.update_service(bedrock_service)
120
+
121
+ logging.info(f"ConversationManager service updated: {old_provider} -> {new_provider}")
122
+
123
+ def get_embedded_tools(self) -> List[Dict[str, Any]]:
124
+ """
125
+ Get embedded/built-in tools in toolSpec format for the web UI.
126
+
127
+ Returns:
128
+ List of tool definitions wrapped in {'toolSpec': tool} format
129
+ """
130
+ try:
131
+ # Get raw builtin tools
132
+ raw_tools = builtin.get_builtin_tools(config=self.config)
133
+
134
+ # Wrap each tool in toolSpec format for web UI compatibility
135
+ embedded_tools = []
136
+ for tool in raw_tools:
137
+ embedded_tools.append({
138
+ 'toolSpec': {
139
+ 'name': tool.get('name', 'unknown'),
140
+ 'description': tool.get('description', ''),
141
+ 'inputSchema': tool.get('input_schema', {})
142
+ }
143
+ })
144
+
145
+ return embedded_tools
146
+
147
+ except Exception as e:
148
+ logging.warning(f"Error getting embedded tools: {e}")
149
+ return []
150
+
151
+ @staticmethod
152
+ def _extract_text_from_content(content: Union[str, List[Dict[str, Any]]]) -> str:
153
+ """
154
+ Extract text from content which can be either a string or list of content blocks.
155
+
156
+ Args:
157
+ content: Either a string or list of content blocks (e.g., [{'text': 'Hello'}])
158
+
159
+ Returns:
160
+ Extracted text as a string
161
+ """
162
+ if isinstance(content, str):
163
+ return content
164
+ elif isinstance(content, list):
165
+ # Extract text from all text blocks and concatenate
166
+ text_parts = []
167
+ for block in content:
168
+ if isinstance(block, dict) and 'text' in block:
169
+ text_parts.append(block['text'])
170
+ return ''.join(text_parts)
171
+ else:
172
+ return ''
173
+
174
+ def create_conversation(self, name: str, model_id: str, instructions: Optional[str] = None,
175
+ compaction_threshold: Optional[float] = None) -> int:
176
+ """
177
+ Create a new conversation.
178
+
179
+ Args:
180
+ name: Name for the conversation
181
+ model_id: Bedrock model ID to use
182
+ instructions: Optional instructions/system prompt for the conversation
183
+ compaction_threshold: Optional compaction threshold override (0.0-1.0, None uses global default)
184
+
185
+ Returns:
186
+ ID of the newly created conversation
187
+ """
188
+ conversation_id = self.database.create_conversation(name, model_id, instructions,
189
+ compaction_threshold=compaction_threshold)
190
+ self.current_conversation_id = conversation_id
191
+ self.current_instructions = instructions
192
+
193
+ # Update context compactor with conversation-specific threshold if set
194
+ if compaction_threshold is not None:
195
+ self.context_compactor.compaction_threshold = compaction_threshold
196
+ logging.info(f"Using conversation-specific compaction threshold: {compaction_threshold:.0%}")
197
+ else:
198
+ # Reset to global default
199
+ self.context_compactor.compaction_threshold = self.rollup_threshold
200
+ logging.info(f"Using global default compaction threshold: {self.rollup_threshold:.0%}")
201
+
202
+ logging.info(f"Created new conversation: {name} (ID: {conversation_id})")
203
+ return conversation_id
204
+
205
+ def load_conversation(self, conversation_id: int) -> bool:
206
+ """
207
+ Load an existing conversation.
208
+
209
+ Args:
210
+ conversation_id: ID of the conversation to load
211
+
212
+ Returns:
213
+ True if loaded successfully, False otherwise
214
+ """
215
+ conversation = self.database.get_conversation(conversation_id)
216
+ if conversation:
217
+ self.current_conversation_id = conversation_id
218
+ self.current_instructions = conversation.get('instructions')
219
+
220
+ # Load conversation-specific max_tokens if set (otherwise use global default)
221
+ conversation_max_tokens = conversation.get('max_tokens')
222
+ if conversation_max_tokens is not None:
223
+ self.max_tokens = conversation_max_tokens
224
+ logging.info(f"Using conversation-specific max_tokens: {conversation_max_tokens}")
225
+ else:
226
+ # Reset to global default (in case previous conversation had custom value)
227
+ self.max_tokens = self.default_max_tokens
228
+ logging.info(f"Using global default max_tokens: {self.default_max_tokens}")
229
+
230
+ # Load conversation-specific compaction_threshold if set (otherwise use global default)
231
+ conversation_compaction_threshold = conversation.get('compaction_threshold')
232
+ if conversation_compaction_threshold is not None:
233
+ self.context_compactor.compaction_threshold = conversation_compaction_threshold
234
+ logging.info(f"Using conversation-specific compaction threshold: {conversation_compaction_threshold:.0%}")
235
+ else:
236
+ # Reset to global default (in case previous conversation had custom value)
237
+ self.context_compactor.compaction_threshold = self.rollup_threshold
238
+ logging.info(f"Using global default compaction threshold: {self.rollup_threshold:.0%}")
239
+
240
+ logging.info(f"Loaded conversation: {conversation['name']} (ID: {conversation_id})")
241
+ return True
242
+ else:
243
+ logging.error(f"Conversation {conversation_id} not found")
244
+ return False
245
+
246
+ def add_user_message(self, content: str) -> int:
247
+ """
248
+ Add a user message to the current conversation.
249
+
250
+ Args:
251
+ content: Message content
252
+
253
+ Returns:
254
+ Message ID
255
+ """
256
+ if not self.current_conversation_id:
257
+ raise ValueError("No active conversation. Create or load a conversation first.")
258
+
259
+ token_count = self.bedrock_service.count_tokens(content)
260
+ message_id = self.database.add_message(
261
+ self.current_conversation_id,
262
+ 'user',
263
+ content,
264
+ token_count
265
+ )
266
+
267
+ logging.debug(f"Added user message ({token_count} tokens)")
268
+
269
+ # Check if rollup is needed after adding the message
270
+ self._check_and_perform_rollup()
271
+
272
+ return message_id
273
+
274
+ def add_assistant_message(self, content: str) -> int:
275
+ """
276
+ Add an assistant message to the current conversation.
277
+
278
+ Args:
279
+ content: Message content
280
+
281
+ Returns:
282
+ Message ID
283
+ """
284
+ if not self.current_conversation_id:
285
+ raise ValueError("No active conversation. Create or load a conversation first.")
286
+
287
+ token_count = self.bedrock_service.count_tokens(content)
288
+ message_id = self.database.add_message(
289
+ self.current_conversation_id,
290
+ 'assistant',
291
+ content,
292
+ token_count
293
+ )
294
+
295
+ logging.debug(f"Added assistant message ({token_count} tokens)")
296
+
297
+ # Check if rollup is needed after adding the message
298
+ self._check_and_perform_rollup()
299
+
300
+ return message_id
301
+
302
+ def get_messages_for_model(self) -> List[Dict[str, Any]]:
303
+ """
304
+ Get messages formatted for model input (excluding rolled-up messages).
305
+ Properly formats tool use and tool result messages for Claude API.
306
+ Validates that tool_use blocks have corresponding tool_result blocks.
307
+
308
+ Returns:
309
+ List of message dictionaries with 'role' and 'content'
310
+ """
311
+ if not self.current_conversation_id:
312
+ return []
313
+
314
+ messages = self.database.get_conversation_messages(
315
+ self.current_conversation_id,
316
+ include_rolled_up=False
317
+ )
318
+
319
+ # Format for model
320
+ formatted_messages = []
321
+ for msg in messages:
322
+ content = msg['content']
323
+
324
+ # Check if this is a tool-related message (stored as JSON)
325
+ if msg['role'] == 'assistant' and content.startswith('['):
326
+ # This is likely a tool_use message stored as JSON
327
+ try:
328
+ content_blocks = json.loads(content)
329
+ formatted_messages.append({
330
+ 'role': 'assistant',
331
+ 'content': content_blocks
332
+ })
333
+ continue
334
+ except json.JSONDecodeError:
335
+ pass # Not JSON, treat as regular message
336
+
337
+ if msg['role'] == 'user' and content.startswith('[TOOL_RESULTS]'):
338
+ # This is a tool results message
339
+ try:
340
+ tool_results_json = content.replace('[TOOL_RESULTS]', '', 1)
341
+ tool_results = json.loads(tool_results_json)
342
+ formatted_messages.append({
343
+ 'role': 'user',
344
+ 'content': tool_results
345
+ })
346
+ continue
347
+ except json.JSONDecodeError:
348
+ pass # Not JSON, treat as regular message
349
+
350
+ # Regular message
351
+ formatted_messages.append({
352
+ 'role': msg['role'],
353
+ 'content': content
354
+ })
355
+
356
+ # Validate tool_use/tool_result pairing to prevent API errors
357
+ validated_messages = []
358
+ orphaned_tool_ids = set() # Track tool_use IDs that were filtered out
359
+ i = 0
360
+
361
+ while i < len(formatted_messages):
362
+ msg = formatted_messages[i]
363
+
364
+ # Check if this is an assistant message with tool_use blocks
365
+ if msg['role'] == 'assistant' and isinstance(msg['content'], list):
366
+ has_tool_use = any(block.get('type') == 'tool_use' for block in msg['content'] if isinstance(block, dict))
367
+
368
+ if has_tool_use:
369
+ # Verify the next message is a user message with tool_results
370
+ if i + 1 < len(formatted_messages):
371
+ next_msg = formatted_messages[i + 1]
372
+ if next_msg['role'] == 'user' and isinstance(next_msg['content'], list):
373
+ has_tool_result = any(block.get('type') == 'tool_result' for block in next_msg['content'] if isinstance(block, dict))
374
+ if has_tool_result:
375
+ # Valid pair - add both messages
376
+ validated_messages.append(msg)
377
+ validated_messages.append(next_msg)
378
+ i += 2
379
+ continue
380
+
381
+ # Orphaned tool_use - collect IDs and filter out tool_use blocks
382
+ tool_use_ids = [block.get('id') for block in msg['content']
383
+ if isinstance(block, dict) and block.get('type') == 'tool_use' and block.get('id')]
384
+ orphaned_tool_ids.update(tool_use_ids)
385
+
386
+ logging.warning(f"Found orphaned tool_use blocks at message {i} (IDs: {tool_use_ids}), filtering out")
387
+ filtered_content = [block for block in msg['content']
388
+ if not (isinstance(block, dict) and block.get('type') == 'tool_use')]
389
+ if filtered_content:
390
+ validated_messages.append({
391
+ 'role': 'assistant',
392
+ 'content': filtered_content
393
+ })
394
+ i += 1
395
+ continue
396
+
397
+ # Check if this is a user message with tool_results for orphaned tool_use blocks
398
+ if msg['role'] == 'user' and isinstance(msg['content'], list) and orphaned_tool_ids:
399
+ # Filter out tool_results that reference orphaned tool_use IDs
400
+ filtered_content = [block for block in msg['content']
401
+ if not (isinstance(block, dict) and
402
+ block.get('type') == 'tool_result' and
403
+ block.get('tool_use_id') in orphaned_tool_ids)]
404
+
405
+ # If we filtered out any tool_results, log it
406
+ if len(filtered_content) < len(msg['content']):
407
+ removed_ids = [block.get('tool_use_id') for block in msg['content']
408
+ if isinstance(block, dict) and
409
+ block.get('type') == 'tool_result' and
410
+ block.get('tool_use_id') in orphaned_tool_ids]
411
+ logging.warning(f"Filtered out orphaned tool_results at message {i} (tool_use_ids: {removed_ids})")
412
+
413
+ # Only add the message if there's content left
414
+ if filtered_content:
415
+ validated_messages.append({
416
+ 'role': 'user',
417
+ 'content': filtered_content
418
+ })
419
+ i += 1
420
+ continue
421
+
422
+ # Regular message or already validated - add it
423
+ validated_messages.append(msg)
424
+ i += 1
425
+
426
+ return validated_messages
427
+
428
+ def get_conversation_history(self, include_rolled_up: bool = False) -> List[Dict]:
429
+ """
430
+ Get full conversation history including metadata.
431
+
432
+ Args:
433
+ include_rolled_up: Whether to include messages that have been rolled up (default: False for chat, True for export)
434
+
435
+ Returns:
436
+ List of message dictionaries with all fields
437
+ """
438
+ if not self.current_conversation_id:
439
+ return []
440
+
441
+ return self.database.get_conversation_messages(
442
+ self.current_conversation_id,
443
+ include_rolled_up=include_rolled_up
444
+ )
445
+
446
+ def get_last_assistant_message(self) -> Optional[str]:
447
+ """
448
+ Get the last assistant message content for copying to clipboard.
449
+
450
+ Returns:
451
+ The text content of the last assistant message, or None if no assistant message exists
452
+ """
453
+ if not self.current_conversation_id:
454
+ return None
455
+
456
+ messages = self.get_conversation_history(include_rolled_up=False)
457
+
458
+ # Find the last assistant message
459
+ for message in reversed(messages):
460
+ if message['role'] == 'assistant':
461
+ content = message['content']
462
+
463
+ # Check if it's a JSON tool use message
464
+ if content.startswith('[') and content.strip().endswith(']'):
465
+ try:
466
+ blocks = json.loads(content)
467
+ if isinstance(blocks, list):
468
+ # Extract text blocks only (skip tool_use blocks)
469
+ text_parts = []
470
+ for block in blocks:
471
+ if isinstance(block, dict) and block.get('type') == 'text':
472
+ text_parts.append(block.get('text', ''))
473
+ if text_parts:
474
+ return '\n'.join(text_parts)
475
+ except (json.JSONDecodeError, ValueError):
476
+ # Not JSON, return as-is
477
+ pass
478
+
479
+ # Check if it's a rollup summary
480
+ if content.startswith('[Summary of previous conversation]'):
481
+ return content
482
+
483
+ # Regular assistant message
484
+ return content
485
+
486
+ return None
487
+
488
+ def _check_and_perform_rollup(self):
489
+ """
490
+ Check if context compaction is needed and perform it if threshold is exceeded.
491
+
492
+ Uses intelligent context compaction with model-specific context window limits.
493
+ Defers compaction if currently in a tool use loop to avoid breaking
494
+ tool_use/tool_result sequences, unless we've reached the emergency threshold.
495
+ """
496
+ if not self.current_conversation_id:
497
+ return
498
+
499
+ # Get current model ID and provider for context limit lookup
500
+ model_id = self._get_current_model_id()
501
+ provider = self._get_current_provider()
502
+
503
+ # Delegate to the intelligent context compactor
504
+ self.context_compactor.check_and_compact(
505
+ conversation_id=self.current_conversation_id,
506
+ model_id=model_id,
507
+ provider=provider,
508
+ in_tool_use_loop=self._in_tool_use_loop
509
+ )
510
+
511
+ def _get_current_model_id(self) -> str:
512
+ """
513
+ Get the model ID for the current conversation.
514
+
515
+ Returns:
516
+ Model ID string, or 'unknown' if not available
517
+ """
518
+ if self.current_conversation_id:
519
+ conv = self.database.get_conversation(self.current_conversation_id)
520
+ if conv:
521
+ return conv.get('model_id', 'unknown')
522
+ return 'unknown'
523
+
524
+ def _get_current_provider(self) -> str:
525
+ """
526
+ Get the provider for the current model.
527
+
528
+ Attempts to determine the provider from:
529
+ 1. The bedrock_service type
530
+ 2. The model ID pattern
531
+
532
+ Returns:
533
+ Provider name string
534
+ """
535
+ # Try to get provider from service type
536
+ if hasattr(self.bedrock_service, 'get_provider_name'):
537
+ provider = self.bedrock_service.get_provider_name()
538
+ if provider:
539
+ return provider.lower().replace(' ', '_')
540
+
541
+ # Fall back to inferring from model ID
542
+ model_id = self._get_current_model_id()
543
+ return get_provider_from_model_id(model_id)
544
+
545
+ def _perform_rollup(self):
546
+ """
547
+ Perform conversation rollup by summarising older messages.
548
+ Ensures tool_use/tool_result pairs are never split.
549
+ """
550
+ # Display rollup start notification
551
+ if self.cli_interface:
552
+ self.cli_interface.print_separator("─")
553
+ self.cli_interface.print_info("⚙️ Starting conversation rollup to manage token usage...")
554
+ self.cli_interface.print_separator("─")
555
+
556
+ messages = self.database.get_conversation_messages(
557
+ self.current_conversation_id,
558
+ include_rolled_up=False
559
+ )
560
+
561
+ if len(messages) <= 2:
562
+ logging.warning("Not enough messages to perform rollup")
563
+ if self.cli_interface:
564
+ self.cli_interface.print_warning("Not enough messages to perform rollup")
565
+ return
566
+
567
+ # Find a safe cutoff point that doesn't split tool_use/tool_result pairs
568
+ # Start with keeping at least the last 2 complete exchanges (4 messages)
569
+ messages_to_keep_count = 4
570
+
571
+ # Look backwards from the cutoff point to ensure we don't split tool pairs
572
+ # If the message at the cutoff is a tool_result, we need to keep its tool_use too
573
+ cutoff_index = len(messages) - messages_to_keep_count
574
+
575
+ while cutoff_index > 0:
576
+ cutoff_msg = messages[cutoff_index] if cutoff_index < len(messages) else None
577
+
578
+ if cutoff_msg and cutoff_msg['content'].startswith('[TOOL_RESULTS]'):
579
+ # This is a tool_result message, we need to keep the preceding tool_use
580
+ # Move cutoff back one more message
581
+ cutoff_index -= 1
582
+ messages_to_keep_count += 1
583
+ else:
584
+ # Safe to cut here
585
+ break
586
+
587
+ # Also check if the message right before cutoff is a tool_use without result
588
+ if cutoff_index > 0:
589
+ prev_msg = messages[cutoff_index - 1]
590
+ if prev_msg['role'] == 'assistant' and prev_msg['content'].startswith('['):
591
+ try:
592
+ # Check if it's a tool_use message
593
+ content_blocks = json.loads(prev_msg['content'])
594
+ if any(block.get('type') == 'tool_use' for block in content_blocks):
595
+ # Move cutoff back to include this tool_use and its result
596
+ cutoff_index -= 1
597
+ messages_to_keep_count += 1
598
+ except:
599
+ pass
600
+
601
+ messages_to_summarise = messages[:cutoff_index] if cutoff_index > 0 else []
602
+
603
+ if not messages_to_summarise or len(messages_to_summarise) == 0:
604
+ logging.warning("No messages available for rollup after ensuring tool pairs stay together")
605
+ if self.cli_interface:
606
+ self.cli_interface.print_warning("No messages available for rollup")
607
+ return
608
+
609
+ # Calculate original token count
610
+ original_token_count = sum(msg['token_count'] for msg in messages_to_summarise)
611
+
612
+ # Display rollup details
613
+ if self.cli_interface:
614
+ self.cli_interface.print_info(f"Summarising {len(messages_to_summarise)} messages ({original_token_count:,} tokens)...")
615
+
616
+ # Create a summary of the older messages
617
+ summary_content = self._create_summary(messages_to_summarise)
618
+ summary_token_count = self.bedrock_service.count_tokens(summary_content)
619
+
620
+ # Add the summary as a user message (Claude doesn't accept 'system' role in messages)
621
+ self.database.add_message(
622
+ self.current_conversation_id,
623
+ 'user',
624
+ f"[Summary of previous conversation]\n{summary_content}",
625
+ summary_token_count
626
+ )
627
+
628
+ # Mark old messages as rolled up
629
+ message_ids = [msg['id'] for msg in messages_to_summarise]
630
+ self.database.mark_messages_as_rolled_up(message_ids)
631
+
632
+ # Record the rollup operation
633
+ self.database.record_rollup(
634
+ self.current_conversation_id,
635
+ len(messages_to_summarise),
636
+ summary_content,
637
+ original_token_count,
638
+ summary_token_count
639
+ )
640
+
641
+ token_reduction = original_token_count - summary_token_count
642
+ logging.info(f"Rollup completed: {len(messages_to_summarise)} messages summarised, "
643
+ f"reduced tokens by {token_reduction}")
644
+
645
+ # Display rollup completion
646
+ if self.cli_interface:
647
+ reduction_pct = (token_reduction / original_token_count * 100) if original_token_count > 0 else 0
648
+ self.cli_interface.print_success(f"✓ Rollup completed: {len(messages_to_summarise)} messages → 1 summary")
649
+ self.cli_interface.print_info(f"Token reduction: {original_token_count:,} → {summary_token_count:,} ({reduction_pct:.1f}% reduction)")
650
+ self.cli_interface.print_separator("─")
651
+
652
+ def _calculate_suggested_max_tokens(self) -> int:
653
+ """
654
+ Calculate a suggested max_tokens value when current limit is hit.
655
+ Uses common model token limits as suggestions.
656
+
657
+ Returns:
658
+ Suggested max_tokens value
659
+ """
660
+ current = self.max_tokens
661
+
662
+ # Common model token limits
663
+ common_limits = [4096, 8192, 16384, 32768, 65536, 131072, 200000]
664
+
665
+ # Find next higher limit
666
+ for limit in common_limits:
667
+ if limit > current:
668
+ return limit
669
+
670
+ # If already at highest, suggest 2x current
671
+ return current * 2
672
+
673
+ def update_conversation_max_tokens(self, new_max_tokens: int) -> bool:
674
+ """
675
+ Update the max_tokens setting for the current conversation.
676
+
677
+ Args:
678
+ new_max_tokens: New max_tokens value
679
+
680
+ Returns:
681
+ True if successful, False otherwise
682
+ """
683
+ if not self.current_conversation_id:
684
+ logging.error("Cannot update max_tokens: no active conversation")
685
+ return False
686
+
687
+ try:
688
+ # Update in database
689
+ self.database.update_conversation_max_tokens(
690
+ self.current_conversation_id,
691
+ new_max_tokens
692
+ )
693
+
694
+ # Update in memory
695
+ self.max_tokens = new_max_tokens
696
+
697
+ logging.info(f"Updated max_tokens for conversation {self.current_conversation_id} to {new_max_tokens}")
698
+ return True
699
+ except Exception as e:
700
+ logging.error(f"Failed to update max_tokens: {e}")
701
+ return False
702
+
703
+ def _detect_synthesis_response(self, assistant_message: str, tool_call_history: List[str]) -> bool:
704
+ """
705
+ Detect if the assistant's response appears to be creating a synthesis/summary document
706
+ that aggregates data from multiple sources.
707
+
708
+ Args:
709
+ assistant_message: The assistant's message content
710
+ tool_call_history: List of tool names called in this conversation turn
711
+
712
+ Returns:
713
+ True if synthesis/aggregation is detected
714
+ """
715
+ if not assistant_message:
716
+ return False
717
+
718
+ message_lower = assistant_message.lower()
719
+
720
+ # Patterns that indicate synthesis/summary documents
721
+ synthesis_patterns = [
722
+ 'executive summary',
723
+ 'cost summary',
724
+ 'overall summary',
725
+ 'combined total',
726
+ 'total savings',
727
+ 'in total',
728
+ 'altogether',
729
+ 'aggregated',
730
+ 'consolidated',
731
+ 'across all accounts',
732
+ 'across all',
733
+ 'total potential',
734
+ 'combined savings',
735
+ 'grand total',
736
+ 'overall cost'
737
+ ]
738
+
739
+ # Check for synthesis patterns in message
740
+ for pattern in synthesis_patterns:
741
+ if pattern in message_lower:
742
+ logging.debug(f"Synthesis pattern detected: '{pattern}'")
743
+ return True
744
+
745
+ # Check if creating summary documents via tools
746
+ summary_tool_patterns = ['summary', 'executive', 'total', 'overview']
747
+ for tool_name in tool_call_history[-5:]: # Check last 5 tools
748
+ tool_lower = tool_name.lower()
749
+ for pattern in summary_tool_patterns:
750
+ if pattern in tool_lower or ('append' in tool_lower and pattern in message_lower):
751
+ logging.debug(f"Summary document creation detected via tool: {tool_name}")
752
+ return True
753
+
754
+ # Check for numerical aggregation patterns with currency
755
+ aggregation_with_numbers = [
756
+ r'total.*\$[\d,]+',
757
+ r'combined.*\$[\d,]+',
758
+ r'overall.*\$[\d,]+',
759
+ r'savings.*\$[\d,]+.*-.*\$[\d,]+' # Range of savings
760
+ ]
761
+
762
+ import re
763
+ for pattern in aggregation_with_numbers:
764
+ if re.search(pattern, message_lower):
765
+ logging.debug(f"Numerical aggregation pattern detected: '{pattern}'")
766
+ return True
767
+
768
+ return False
769
+
770
+ def _extract_numerical_data(self, content: str) -> Optional[str]:
771
+ """
772
+ Extract numerical data and key findings from tool results.
773
+
774
+ Args:
775
+ content: Tool result content string
776
+
777
+ Returns:
778
+ Extracted numerical data summary or None
779
+ """
780
+ try:
781
+ import json
782
+ import re
783
+
784
+ # Remove [TOOL_RESULTS] prefix if present
785
+ if content.startswith('[TOOL_RESULTS]'):
786
+ content = content[len('[TOOL_RESULTS]'):].strip()
787
+
788
+ # Try to parse as JSON
789
+ try:
790
+ data = json.loads(content)
791
+ except:
792
+ # Not JSON, try to extract numbers from text
793
+ data = None
794
+
795
+ findings = []
796
+
797
+ # Extract from JSON structure
798
+ if data:
799
+ # Look for common patterns in tool results
800
+ if isinstance(data, dict):
801
+ # Look for summary, total, savings, cost patterns
802
+ for key in ['summary', 'total', 'savings', 'cost', 'amount', 'count', 'potential_savings']:
803
+ if key in data:
804
+ value = data[key]
805
+ if isinstance(value, (int, float)):
806
+ findings.append(f"{key}: {value}")
807
+ elif isinstance(value, dict):
808
+ # Nested summary data
809
+ for subkey, subval in value.items():
810
+ if isinstance(subval, (int, float)):
811
+ findings.append(f"{key}.{subkey}: {subval}")
812
+
813
+ elif isinstance(data, list) and len(data) > 0:
814
+ # List of items - report count
815
+ findings.append(f"Items count: {len(data)}")
816
+
817
+ # Extract currency amounts from text (e.g., $1,234.56, USD $1,234)
818
+ currency_pattern = r'(?:USD\s*)?\$\s*[\d,]+(?:\.\d{2})?(?:\s*(?:million|thousand|billion|[KMB]))?'
819
+ currencies = re.findall(currency_pattern, content, re.IGNORECASE)
820
+ if currencies:
821
+ # Limit to first 5 to avoid overwhelming the summary
822
+ findings.extend([f"Currency value: {c.strip()}" for c in currencies[:5]])
823
+
824
+ # Extract percentages
825
+ percentage_pattern = r'\d+(?:\.\d+)?%'
826
+ percentages = re.findall(percentage_pattern, content)
827
+ if percentages:
828
+ findings.extend([f"Percentage: {p}" for p in percentages[:5]])
829
+
830
+ # Extract large numbers (likely significant)
831
+ number_pattern = r'\b\d{1,3}(?:,\d{3})+(?:\.\d+)?\b'
832
+ numbers = re.findall(number_pattern, content)
833
+ if numbers:
834
+ findings.extend([f"Value: {n}" for n in numbers[:5]])
835
+
836
+ if findings:
837
+ return "Key data: " + "; ".join(findings[:10]) # Limit to 10 findings
838
+
839
+ return None
840
+
841
+ except Exception as e:
842
+ logging.debug(f"Error extracting numerical data: {e}")
843
+ return None
844
+
845
+ def _create_summary(self, messages: List[Dict]) -> str:
846
+ """
847
+ Create a summary of messages using the Bedrock model.
848
+
849
+ Args:
850
+ messages: List of message dictionaries to summarise
851
+
852
+ Returns:
853
+ Summary text
854
+ """
855
+ # Build a prompt for summarisation - clean up tool use content
856
+ conversation_text = []
857
+ numerical_data_found = []
858
+
859
+ for msg in messages:
860
+ role = msg['role'].capitalize()
861
+ content = msg['content']
862
+
863
+ # Parse and clean tool-related content for better summarization
864
+ if content.startswith('[TOOL_RESULTS]'):
865
+ # Extract numerical data from tool results
866
+ numerical_summary = self._extract_numerical_data(content)
867
+ if numerical_summary:
868
+ conversation_text.append(f"{role}: [Tool execution results - {numerical_summary}]")
869
+ numerical_data_found.append(numerical_summary)
870
+ else:
871
+ conversation_text.append(f"{role}: [Received tool execution results]")
872
+ elif content.startswith('['):
873
+ # Try to parse tool_use blocks
874
+ try:
875
+ import json
876
+ content_blocks = json.loads(content)
877
+ text_parts = []
878
+ tool_parts = []
879
+ for block in content_blocks:
880
+ if block.get('type') == 'text':
881
+ text_parts.append(block.get('text', ''))
882
+ elif block.get('type') == 'tool_use':
883
+ tool_parts.append(f"used tool {block.get('name')}")
884
+
885
+ # Combine text and tool use descriptions
886
+ if text_parts:
887
+ conversation_text.append(f"{role}: {' '.join(text_parts)}")
888
+ if tool_parts:
889
+ conversation_text.append(f"{role}: [Called tools: {', '.join(tool_parts)}]")
890
+ except:
891
+ # If parsing fails, include content as-is
892
+ conversation_text.append(f"{role}: {content}")
893
+ else:
894
+ conversation_text.append(f"{role}: {content}")
895
+
896
+ full_conversation = '\n\n'.join(conversation_text)
897
+
898
+ # Calculate sensible token targets
899
+ original_tokens = sum(msg['token_count'] for msg in messages)
900
+ target_tokens = int(original_tokens * self.rollup_summary_ratio)
901
+ # Ensure minimum of 500 tokens and maximum of 3000 for summary
902
+ max_summary_tokens = max(500, min(target_tokens, 3000))
903
+
904
+ # Build the summary prompt with emphasis on numerical data if present
905
+ numerical_data_section = ""
906
+ if numerical_data_found:
907
+ numerical_data_section = f"""
908
+ CRITICAL - NUMERICAL DATA DETECTED:
909
+ The conversation contains important numerical data from tool results. You MUST preserve these exact values in your summary:
910
+ {chr(10).join(['- ' + data for data in numerical_data_found[:20]])}
911
+
912
+ When summarising, explicitly include these numerical values to maintain accuracy.
913
+ """
914
+
915
+ summary_prompt = [
916
+ {
917
+ 'role': 'user',
918
+ 'content': f"""Please provide a comprehensive summary of the following conversation.
919
+
920
+ IMPORTANT: Focus on preserving:
921
+ - Key decisions and conclusions
922
+ - Important data, numbers, and findings (especially calculations, totals, costs, savings)
923
+ - Action items and tasks completed
924
+ - Critical context needed to continue the conversation
925
+ - Any errors or corrections that were identified
926
+ {numerical_data_section}
927
+ The original conversation contained {len(messages)} messages with {original_tokens} tokens.
928
+ Your summary should capture the essential information in approximately {target_tokens} tokens.
929
+
930
+ Conversation to summarise:
931
+ {full_conversation}
932
+
933
+ Summary:"""
934
+ }
935
+ ]
936
+
937
+ # Use the current model to generate the summary
938
+ response = self.bedrock_service.invoke_model(
939
+ summary_prompt,
940
+ max_tokens=max_summary_tokens,
941
+ temperature=0.3 # Lower temperature for more focused summary
942
+ )
943
+
944
+ if response and response.get('content'):
945
+ summary_text = response['content'].strip()
946
+ # Verify summary is not trivial
947
+ if len(summary_text) < 50:
948
+ logging.warning(f"Summary too brief ({len(summary_text)} chars), using detailed fallback")
949
+ return f"Previous conversation covered {len(messages)} messages discussing:\n" + '\n'.join(conversation_text[:5])
950
+ return summary_text
951
+ else:
952
+ # Fallback to simple concatenation if summarisation fails
953
+ logging.warning("Model summarisation failed, using detailed fallback")
954
+ return f"Previous conversation covered {len(messages)} messages:\n" + '\n'.join(conversation_text[:10])
955
+
956
+ def get_active_conversations(self) -> List[Dict]:
957
+ """
958
+ Get list of all active conversations.
959
+
960
+ Returns:
961
+ List of conversation dictionaries
962
+ """
963
+ return self.database.get_active_conversations()
964
+
965
+ def get_current_token_count(self) -> int:
966
+ """
967
+ Get the current token count for the active conversation.
968
+
969
+ Returns:
970
+ Total token count
971
+ """
972
+ if not self.current_conversation_id:
973
+ return 0
974
+
975
+ return self.database.get_conversation_token_count(self.current_conversation_id)
976
+
977
+ def get_current_conversation_info(self) -> Optional[Dict]:
978
+ """
979
+ Get information about the current conversation.
980
+
981
+ Returns:
982
+ Conversation dictionary or None
983
+ """
984
+ if not self.current_conversation_id:
985
+ return None
986
+
987
+ return self.database.get_conversation(self.current_conversation_id)
988
+
989
+ def get_context_window(self) -> int:
990
+ """
991
+ Get the context window size for the current conversation's model.
992
+
993
+ Uses the ContextLimitResolver to determine the actual context window
994
+ based on the model ID and provider.
995
+
996
+ Returns:
997
+ Context window size in tokens, or default of 8192 if unavailable
998
+ """
999
+ conv_info = self.get_current_conversation_info()
1000
+ if not conv_info:
1001
+ return 8192 # Safe default
1002
+
1003
+ model_id = conv_info.get('model_id', '')
1004
+ if not model_id:
1005
+ return 8192
1006
+
1007
+ # Determine provider from model ID
1008
+ provider = get_provider_from_model_id(model_id)
1009
+
1010
+ # Get context window from resolver
1011
+ return self.context_limit_resolver.get_context_window(model_id, provider)
1012
+
1013
+ def change_model(self, new_model_id: str) -> bool:
1014
+ """
1015
+ Change the model for the current conversation.
1016
+
1017
+ Args:
1018
+ new_model_id: ID of the new model to use
1019
+
1020
+ Returns:
1021
+ True if successful, False otherwise
1022
+ """
1023
+ if not self.current_conversation_id:
1024
+ logging.warning("No conversation loaded to change model")
1025
+ return False
1026
+
1027
+ try:
1028
+ # Update conversation model in database
1029
+ cursor = self.database.conn.cursor()
1030
+ cursor.execute('''
1031
+ UPDATE conversations
1032
+ SET model_id = ?
1033
+ WHERE id = ?
1034
+ ''', (new_model_id, self.current_conversation_id))
1035
+ self.database.conn.commit()
1036
+
1037
+ # Update bedrock service to use new model
1038
+ self.bedrock_service.set_model(new_model_id)
1039
+
1040
+ logging.info(f"Changed model to {new_model_id} for conversation {self.current_conversation_id}")
1041
+ return True
1042
+
1043
+ except Exception as e:
1044
+ logging.error(f"Failed to change model: {e}")
1045
+ self.database.conn.rollback()
1046
+ return False
1047
+
1048
+ def update_instructions(self, instructions: Optional[str]) -> bool:
1049
+ """
1050
+ Update the instructions/system prompt for the current conversation.
1051
+
1052
+ Args:
1053
+ instructions: New instructions (None to clear)
1054
+
1055
+ Returns:
1056
+ True if successful, False otherwise
1057
+ """
1058
+ if not self.current_conversation_id:
1059
+ logging.warning("No conversation loaded to update instructions")
1060
+ return False
1061
+
1062
+ try:
1063
+ # Update instructions in database
1064
+ self.database.update_conversation_instructions(self.current_conversation_id, instructions)
1065
+
1066
+ logging.info(f"Updated instructions for conversation {self.current_conversation_id}")
1067
+ return True
1068
+
1069
+ except Exception as e:
1070
+ logging.error(f"Failed to update instructions: {e}")
1071
+ return False
1072
+
1073
+ def get_model_usage_breakdown(self) -> List[Dict]:
1074
+ """
1075
+ Get per-model token usage breakdown for the current conversation.
1076
+
1077
+ Returns:
1078
+ List of dictionaries with model usage details
1079
+ """
1080
+ if not self.current_conversation_id:
1081
+ return []
1082
+
1083
+ return self.database.get_model_usage_breakdown(self.current_conversation_id)
1084
+
1085
+ def _get_embedded_system_instructions(self) -> str:
1086
+ """
1087
+ Generate embedded system instructions that take priority over all other instructions.
1088
+ These include Spark's identity and current date/time with timezone.
1089
+
1090
+ Returns:
1091
+ Embedded system instructions string
1092
+ """
1093
+ # Get current datetime with timezone
1094
+ now = datetime.now().astimezone()
1095
+
1096
+ # Format: "Monday, 17 November 2025 at 02:30:45 PM AEDT (UTC+1100)"
1097
+ datetime_str = now.strftime("%A, %d %B %Y at %I:%M:%S %p %Z (UTC%z)")
1098
+
1099
+ # Build embedded instructions
1100
+ embedded_instructions = f"""Your name is Spark which is short for "Secure Personal AI Research Kit".
1101
+
1102
+ Current date and time: {datetime_str}"""
1103
+
1104
+ return embedded_instructions
1105
+
1106
+ def _get_combined_instructions(self) -> Optional[str]:
1107
+ """
1108
+ Combine embedded, global and conversation-specific instructions.
1109
+ Priority order (highest to lowest):
1110
+ 1. Embedded system instructions (identity, date/time)
1111
+ 2. Global instructions (prepended to prevent override)
1112
+ 3. Conversation-specific instructions
1113
+
1114
+ Returns:
1115
+ Combined instructions string, or None if no instructions exist
1116
+ """
1117
+ instructions_parts = []
1118
+
1119
+ # Add embedded system instructions first (highest priority - always present)
1120
+ instructions_parts.append(self._get_embedded_system_instructions())
1121
+
1122
+ # Add global instructions second (if they exist)
1123
+ if self.global_instructions:
1124
+ instructions_parts.append(self.global_instructions)
1125
+
1126
+ # Add conversation-specific instructions last (if they exist)
1127
+ if self.current_instructions:
1128
+ instructions_parts.append(self.current_instructions)
1129
+
1130
+ # Return combined instructions (always at least embedded instructions)
1131
+ return '\n\n'.join(instructions_parts)
1132
+
1133
+ def get_all_mcp_server_names(self) -> List[str]:
1134
+ """Get names of all available MCP servers."""
1135
+ if not self.mcp_manager:
1136
+ return []
1137
+ # MCPManager uses 'clients' attribute, not 'servers'
1138
+ if not hasattr(self.mcp_manager, 'clients'):
1139
+ logging.warning("MCPManager does not have 'clients' attribute")
1140
+ return []
1141
+ return list(self.mcp_manager.clients.keys())
1142
+
1143
+ def get_mcp_server_states(self) -> List[Dict]:
1144
+ """
1145
+ Get enabled/disabled state for all MCP servers in current conversation.
1146
+
1147
+ Returns:
1148
+ List of dicts with 'server_name' and 'enabled' keys
1149
+ """
1150
+ if not self.current_conversation_id or not self.mcp_manager:
1151
+ return []
1152
+
1153
+ all_servers = self.get_all_mcp_server_names()
1154
+ return self.database.get_all_mcp_server_states(self.current_conversation_id, all_servers)
1155
+
1156
+ def set_mcp_server_enabled(self, server_name: str, enabled: bool) -> bool:
1157
+ """
1158
+ Enable or disable an MCP server for the current conversation.
1159
+ Invalidates tool cache to force reload with new server states.
1160
+
1161
+ Args:
1162
+ server_name: Name of the MCP server
1163
+ enabled: True to enable, False to disable
1164
+
1165
+ Returns:
1166
+ True if successful, False otherwise
1167
+ """
1168
+ if not self.current_conversation_id:
1169
+ logging.error("No active conversation")
1170
+ return False
1171
+
1172
+ if not self.mcp_manager:
1173
+ logging.error("MCP manager not available")
1174
+ return False
1175
+
1176
+ # Check if server exists (MCPManager uses 'clients' attribute)
1177
+ if not hasattr(self.mcp_manager, 'clients') or server_name not in self.mcp_manager.clients:
1178
+ logging.error(f"MCP server '{server_name}' not found")
1179
+ return False
1180
+
1181
+ # Update database
1182
+ if self.database.set_mcp_server_enabled(self.current_conversation_id, server_name, enabled):
1183
+ # Invalidate tools cache to force reload with new enabled servers
1184
+ self._tools_cache = None
1185
+ logging.info(f"Invalidated tools cache after {'enabling' if enabled else 'disabling'} server '{server_name}'")
1186
+ return True
1187
+ return False
1188
+
1189
+ def _get_mcp_tools(self) -> List[Dict[str, Any]]:
1190
+ """
1191
+ Get available tools from MCP servers and built-in tools in Claude-compatible format.
1192
+
1193
+ Returns:
1194
+ List of tool definitions
1195
+ """
1196
+ # Cache tools to avoid repeated async calls
1197
+ if self._tools_cache is not None:
1198
+ logging.debug(f"Returning cached tools: {len(self._tools_cache)} tools")
1199
+ return self._tools_cache
1200
+
1201
+ # Start with built-in tools (always available)
1202
+ all_tools = []
1203
+ try:
1204
+ builtin_tool_list = builtin.get_builtin_tools(config=self.config)
1205
+ for tool in builtin_tool_list:
1206
+ # Mark as built-in tool
1207
+ tool['server'] = 'builtin'
1208
+ tool['original_name'] = tool['name'] # Built-in tools don't need renaming
1209
+ all_tools.append(tool)
1210
+ logging.info(f"Loaded {len(builtin_tool_list)} built-in tool(s)")
1211
+ except Exception as e:
1212
+ logging.error(f"Failed to load built-in tools: {e}")
1213
+
1214
+ # Return early if no MCP manager (just built-in tools)
1215
+ if not self.mcp_manager:
1216
+ self._tools_cache = all_tools
1217
+ return all_tools
1218
+
1219
+ try:
1220
+ logging.debug("Fetching tools from MCP servers...")
1221
+
1222
+ # Check if we're already in a running event loop (e.g., from FastAPI)
1223
+ try:
1224
+ running_loop = asyncio.get_running_loop()
1225
+ # We're in an async context - use run_coroutine_threadsafe
1226
+ logging.debug("Detected running event loop, using thread-safe approach")
1227
+
1228
+ # Run the coroutine in the existing loop from a thread pool
1229
+ def run_in_loop():
1230
+ # Create a new event loop for this thread
1231
+ new_loop = asyncio.new_event_loop()
1232
+ asyncio.set_event_loop(new_loop)
1233
+ try:
1234
+ result = new_loop.run_until_complete(
1235
+ asyncio.wait_for(
1236
+ self.mcp_manager.list_all_tools(),
1237
+ timeout=10.0
1238
+ )
1239
+ )
1240
+ return result
1241
+ except asyncio.TimeoutError:
1242
+ logging.error("Timeout fetching MCP tools after 10 seconds")
1243
+ return []
1244
+ finally:
1245
+ new_loop.close()
1246
+
1247
+ # Run in thread pool to avoid event loop conflict
1248
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
1249
+ future = executor.submit(run_in_loop)
1250
+ mcp_tools = future.result(timeout=15.0) # Give extra time for thread overhead
1251
+
1252
+ except RuntimeError:
1253
+ # No running event loop - we're in sync context
1254
+ logging.debug("No running event loop detected, using standard approach")
1255
+
1256
+ # Use the initialization loop if available, otherwise create new one
1257
+ if hasattr(self.mcp_manager, '_initialization_loop') and self.mcp_manager._initialization_loop:
1258
+ loop = self.mcp_manager._initialization_loop
1259
+ # Add timeout to prevent indefinite hanging
1260
+ mcp_tools = loop.run_until_complete(
1261
+ asyncio.wait_for(
1262
+ self.mcp_manager.list_all_tools(),
1263
+ timeout=10.0 # 10 second timeout
1264
+ )
1265
+ )
1266
+ else:
1267
+ # Fallback: create temporary loop (shouldn't normally happen)
1268
+ loop = asyncio.new_event_loop()
1269
+ asyncio.set_event_loop(loop)
1270
+ try:
1271
+ mcp_tools = loop.run_until_complete(
1272
+ asyncio.wait_for(
1273
+ self.mcp_manager.list_all_tools(),
1274
+ timeout=10.0
1275
+ )
1276
+ )
1277
+ except asyncio.TimeoutError:
1278
+ logging.error("Timeout fetching MCP tools after 10 seconds")
1279
+ mcp_tools = []
1280
+ finally:
1281
+ loop.close()
1282
+
1283
+ logging.debug(f"Fetched {len(mcp_tools)} tools from MCP servers")
1284
+
1285
+ # Detect and handle duplicate tool names by prefixing with server name
1286
+ tool_name_counts = {}
1287
+ for tool in mcp_tools:
1288
+ tool_name = tool['name']
1289
+ tool_name_counts[tool_name] = tool_name_counts.get(tool_name, 0) + 1
1290
+
1291
+ # Identify which tools need prefixing (appear more than once)
1292
+ duplicates = {name for name, count in tool_name_counts.items() if count > 1}
1293
+
1294
+ if duplicates:
1295
+ logging.warning(f"Found {len(duplicates)} duplicate tool names across servers: {', '.join(sorted(duplicates))}")
1296
+
1297
+ # Convert to Claude format, prefixing duplicates with server name
1298
+ mcp_claude_tools = []
1299
+ for tool in mcp_tools:
1300
+ original_name = tool['name']
1301
+ server_name = tool.get('server', 'unknown')
1302
+
1303
+ # If tool name is duplicated, prefix with server name
1304
+ if original_name in duplicates:
1305
+ prefixed_name = f"{server_name}__{original_name}"
1306
+ logging.info(f"Renaming duplicate tool '{original_name}' from server '{server_name}' to '{prefixed_name}'")
1307
+ tool_name = prefixed_name
1308
+ else:
1309
+ tool_name = original_name
1310
+
1311
+ mcp_claude_tools.append({
1312
+ 'name': tool_name,
1313
+ 'description': tool['description'],
1314
+ 'input_schema': tool['input_schema'],
1315
+ 'server': server_name, # Keep server info for tool calling
1316
+ 'original_name': original_name # Keep original name for MCP calls
1317
+ })
1318
+
1319
+ # Filter MCP tools based on enabled servers for this conversation
1320
+ if self.current_conversation_id:
1321
+ filtered_tools = []
1322
+ for tool in mcp_claude_tools:
1323
+ server_name = tool['server']
1324
+ if self.database.is_mcp_server_enabled(self.current_conversation_id, server_name):
1325
+ filtered_tools.append(tool)
1326
+ else:
1327
+ logging.debug(f"Excluding tool '{tool['name']}' from disabled server '{server_name}'")
1328
+
1329
+ disabled_count = len(mcp_claude_tools) - len(filtered_tools)
1330
+ if disabled_count > 0:
1331
+ logging.info(f"Filtered out {disabled_count} tools from disabled MCP servers")
1332
+ mcp_claude_tools = filtered_tools
1333
+
1334
+ # Merge MCP tools with built-in tools
1335
+ all_tools.extend(mcp_claude_tools)
1336
+
1337
+ self._tools_cache = all_tools
1338
+ logging.info(f"Loaded {len(all_tools)} total tools: {len(all_tools) - len(mcp_claude_tools)} built-in, {len(mcp_claude_tools)} from MCP servers ({len(duplicates)} renamed to resolve conflicts)")
1339
+ return all_tools
1340
+
1341
+ except Exception as e:
1342
+ logging.error(f"Failed to get MCP tools: {e}", exc_info=True)
1343
+ # Still return built-in tools even if MCP tools fail
1344
+ self._tools_cache = all_tools
1345
+ return all_tools
1346
+
1347
+ def _call_mcp_tool(self, tool_name: str, tool_input: Dict[str, Any],
1348
+ user_prompt: str = "") -> Tuple[str, int, bool]:
1349
+ """
1350
+ Call an MCP tool or built-in tool and return the result with metrics.
1351
+ Handles prefixed tool names (server__toolname) for duplicate resolution.
1352
+
1353
+ Args:
1354
+ tool_name: Name of the tool to call (may be prefixed with server name)
1355
+ tool_input: Tool input parameters
1356
+ user_prompt: The original user prompt that triggered this tool call
1357
+
1358
+ Returns:
1359
+ Tuple of (result_string, execution_time_ms, is_error)
1360
+ """
1361
+ import time
1362
+
1363
+ start_time = time.time()
1364
+ is_error = False
1365
+
1366
+ try:
1367
+ # Check if this is a built-in tool
1368
+ is_builtin_tool = False
1369
+ original_tool_name = tool_name
1370
+
1371
+ if self._tools_cache:
1372
+ for cached_tool in self._tools_cache:
1373
+ if cached_tool['name'] == tool_name:
1374
+ if cached_tool.get('server') == 'builtin':
1375
+ is_builtin_tool = True
1376
+ original_tool_name = cached_tool['original_name']
1377
+ logging.debug(f"Identified built-in tool: {original_tool_name}")
1378
+ break
1379
+ elif '__' in tool_name:
1380
+ # Prefixed MCP tool name
1381
+ original_tool_name = cached_tool['original_name']
1382
+ logging.debug(f"Resolved prefixed tool name '{tool_name}' to original '{original_tool_name}'")
1383
+ break
1384
+
1385
+ # Execute built-in tool
1386
+ if is_builtin_tool:
1387
+ logging.debug(f"Calling built-in tool: {original_tool_name} with input: {tool_input}")
1388
+ result = builtin.execute_builtin_tool(original_tool_name, tool_input, config=self.config)
1389
+ execution_time = int((time.time() - start_time) * 1000)
1390
+
1391
+ if result.get('success'):
1392
+ # Format result as string
1393
+ result_data = result.get('result', {})
1394
+ if isinstance(result_data, dict):
1395
+ result_str = json.dumps(result_data, indent=2)
1396
+ else:
1397
+ result_str = str(result_data)
1398
+ return result_str, execution_time, False
1399
+ else:
1400
+ error_msg = result.get('error', 'Unknown error')
1401
+ return f"Error: {error_msg}", execution_time, True
1402
+
1403
+ # Execute MCP tool
1404
+ if not self.mcp_manager:
1405
+ return "Error: MCP manager not available", 0, True
1406
+
1407
+ logging.debug(f"Calling MCP tool: {original_tool_name} with input: {tool_input}")
1408
+
1409
+ # Check if we're in a running event loop (e.g., from FastAPI)
1410
+ try:
1411
+ running_loop = asyncio.get_running_loop()
1412
+ # We're in an async context - run in a separate thread
1413
+ logging.debug("Detected running event loop, using thread-safe approach for tool call")
1414
+
1415
+ def run_tool_in_loop():
1416
+ # Create a new event loop for this thread
1417
+ new_loop = asyncio.new_event_loop()
1418
+ asyncio.set_event_loop(new_loop)
1419
+ try:
1420
+ result = new_loop.run_until_complete(
1421
+ asyncio.wait_for(
1422
+ self.mcp_manager.call_tool(original_tool_name, tool_input),
1423
+ timeout=30.0
1424
+ )
1425
+ )
1426
+ return result
1427
+ except asyncio.TimeoutError:
1428
+ logging.error(f"Timeout calling MCP tool {original_tool_name} after 30 seconds")
1429
+ return None
1430
+ finally:
1431
+ new_loop.close()
1432
+
1433
+ # Run in thread pool to avoid event loop conflict
1434
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
1435
+ future = executor.submit(run_tool_in_loop)
1436
+ result = future.result(timeout=35.0) # Give extra time for thread overhead
1437
+
1438
+ if result is None:
1439
+ execution_time = int((time.time() - start_time) * 1000)
1440
+ return f"Error: Tool execution timed out after 30 seconds", execution_time, True
1441
+
1442
+ except RuntimeError:
1443
+ # No running event loop - we're in sync context
1444
+ logging.debug("No running event loop detected, using standard approach for tool call")
1445
+
1446
+ # Use the initialisation loop if available, otherwise create a temporary one
1447
+ if hasattr(self.mcp_manager, '_initialization_loop') and self.mcp_manager._initialization_loop:
1448
+ loop = self.mcp_manager._initialization_loop
1449
+ should_close_loop = False
1450
+ logging.debug("Using stored initialisation event loop for tool call")
1451
+ else:
1452
+ loop = asyncio.new_event_loop()
1453
+ asyncio.set_event_loop(loop)
1454
+ should_close_loop = True
1455
+ logging.warning("No stored event loop found, creating temporary loop")
1456
+
1457
+ try:
1458
+ result = loop.run_until_complete(
1459
+ asyncio.wait_for(
1460
+ self.mcp_manager.call_tool(original_tool_name, tool_input),
1461
+ timeout=30.0 # 30 second timeout for tool execution
1462
+ )
1463
+ )
1464
+ except asyncio.TimeoutError:
1465
+ logging.error(f"Timeout calling MCP tool {original_tool_name} after 30 seconds")
1466
+ execution_time = int((time.time() - start_time) * 1000)
1467
+ return f"Error: Tool execution timed out after 30 seconds", execution_time, True
1468
+ finally:
1469
+ # Only close the loop if we created it temporarily
1470
+ if should_close_loop:
1471
+ loop.close()
1472
+
1473
+ execution_time = int((time.time() - start_time) * 1000)
1474
+
1475
+ if result and not result.get('isError'):
1476
+ # Extract text content from result
1477
+ content_parts = []
1478
+ for content in result.get('content', []):
1479
+ if content.get('type') == 'text':
1480
+ content_parts.append(content.get('text', ''))
1481
+
1482
+ result_str = '\n'.join(content_parts) if content_parts else 'Tool executed successfully (no output)'
1483
+ return result_str, execution_time, False
1484
+ else:
1485
+ error_msg = "Tool execution failed"
1486
+ if result:
1487
+ for content in result.get('content', []):
1488
+ if content.get('type') == 'text':
1489
+ error_msg = content.get('text', error_msg)
1490
+ return f"Error: {error_msg}", execution_time, True
1491
+
1492
+ except Exception as e:
1493
+ execution_time = int((time.time() - start_time) * 1000)
1494
+ logging.error(f"Failed to call MCP tool {tool_name}: {e}")
1495
+ return f"Error calling tool: {str(e)}", execution_time, True
1496
+
1497
+ def send_message(self, user_message: str) -> Optional[str]:
1498
+ """
1499
+ Send a user message and get the assistant's response.
1500
+ Handles MCP tool calls automatically.
1501
+
1502
+ Args:
1503
+ user_message: User's message content
1504
+
1505
+ Returns:
1506
+ Assistant's response content or None on failure
1507
+ """
1508
+ if not self.current_conversation_id:
1509
+ raise ValueError("No active conversation. Create or load a conversation first.")
1510
+
1511
+ logging.debug(f"send_message called with: {user_message[:50]}...")
1512
+
1513
+ # NEW: Prompt inspection before processing
1514
+ if self.prompt_inspector and self.prompt_inspector.enabled:
1515
+ inspection_result = self.prompt_inspector.inspect_prompt(
1516
+ prompt=user_message,
1517
+ user_guid=self.user_guid or 'unknown',
1518
+ conversation_id=self.current_conversation_id
1519
+ )
1520
+
1521
+ if inspection_result.blocked:
1522
+ # Log violation and notify user
1523
+ if self.cli_interface:
1524
+ self.cli_interface.display_prompt_violation(inspection_result)
1525
+ logging.warning(f"Prompt blocked: {inspection_result.explanation}")
1526
+ return None
1527
+
1528
+ elif inspection_result.needs_confirmation:
1529
+ # Show warning and ask for confirmation
1530
+ if self.cli_interface:
1531
+ confirmed = self.cli_interface.confirm_risky_prompt(inspection_result)
1532
+ if not confirmed:
1533
+ logging.info("User declined to send risky prompt")
1534
+ return None
1535
+
1536
+ # Use sanitised version if available
1537
+ if inspection_result.sanitised_prompt:
1538
+ logging.info("Using sanitised version of prompt")
1539
+ user_message = inspection_result.sanitised_prompt
1540
+
1541
+ # Check if this is the first message - if so, prepend file context
1542
+ messages = self.get_conversation_history()
1543
+ is_first_message = len(messages) == 0
1544
+
1545
+ if is_first_message:
1546
+ file_context = self._get_file_context()
1547
+ if file_context:
1548
+ # Prepend file context to the first user message
1549
+ user_message = f"{file_context}\n\n{user_message}"
1550
+ logging.info("Added file context to first message")
1551
+
1552
+ # Add user message to conversation
1553
+ self.add_user_message(user_message)
1554
+ logging.debug("User message added to database")
1555
+
1556
+ # Get available tools
1557
+ logging.debug("About to fetch MCP tools...")
1558
+ all_tools = self._get_mcp_tools()
1559
+ logging.debug(f"Got {len(all_tools)} tools")
1560
+
1561
+ # Use tool selector to choose relevant tools for this conversation
1562
+ # This significantly reduces token usage by only sending relevant tools
1563
+ tools = self.tool_selector.select_tools(
1564
+ all_tools=all_tools,
1565
+ user_message=user_message,
1566
+ conversation_history=self.get_messages_for_model() if all_tools else None
1567
+ )
1568
+ logging.debug(f"Tool selector reduced {len(all_tools)} tools to {len(tools)} relevant tools")
1569
+
1570
+ # Tool use loop - continue until model gives a final answer
1571
+ # Set flag to defer rollup during tool use sequences (prevents splitting tool_use/tool_result pairs)
1572
+ self._in_tool_use_loop = True
1573
+ iteration = 0
1574
+ tool_call_history = [] # Track tool calls to detect loops
1575
+
1576
+ while iteration < self.max_tool_iterations:
1577
+ iteration += 1
1578
+ logging.debug(f"Tool use iteration {iteration}/{self.max_tool_iterations}")
1579
+
1580
+ # Get conversation history for the model
1581
+ messages = self.get_messages_for_model()
1582
+ logging.debug(f"Sending {len(messages)} messages to model (tools: {len(tools) if tools else 0})")
1583
+
1584
+ # Filter tools to only include Claude API fields (remove internal metadata)
1585
+ filtered_tools = None
1586
+ if tools:
1587
+ filtered_tools = [
1588
+ {
1589
+ 'name': tool['name'],
1590
+ 'description': tool['description'],
1591
+ 'input_schema': tool['input_schema']
1592
+ }
1593
+ for tool in tools
1594
+ ]
1595
+
1596
+ # Token Management: Check limits before making API call
1597
+ if self.token_manager:
1598
+ # Estimate input tokens
1599
+ input_token_estimate = self.bedrock_service.count_message_tokens(messages)
1600
+
1601
+ # Check limits
1602
+ model_id = self.bedrock_service.current_model_id
1603
+ region = self.bedrock_service.bedrock_runtime_client.meta.region_name
1604
+
1605
+ allowed, warning_message, limit_status = self.token_manager.check_limits_before_request(
1606
+ model_id, region, input_token_estimate, self.max_tokens
1607
+ )
1608
+
1609
+ # Display warnings based on limit status
1610
+ if warning_message and self.cli_interface:
1611
+ if limit_status == LimitStatus.WARNING_75:
1612
+ self.cli_interface.print_budget_warning(warning_message, "75")
1613
+ elif limit_status == LimitStatus.WARNING_85:
1614
+ self.cli_interface.print_budget_warning(warning_message, "85")
1615
+ elif limit_status == LimitStatus.WARNING_95:
1616
+ self.cli_interface.print_budget_warning(warning_message, "95")
1617
+
1618
+ # Handle limit exceeded
1619
+ if not allowed:
1620
+ if self.cli_interface:
1621
+ self.cli_interface.print_separator("─")
1622
+ self.cli_interface.print_error("Token Limit Reached")
1623
+ self.cli_interface.print_error(warning_message)
1624
+ self.cli_interface.print_separator("─")
1625
+
1626
+ # Offer override if allowed
1627
+ if self.token_manager.allow_override:
1628
+ override_accepted, additional_percentage = self.cli_interface.prompt_budget_override()
1629
+
1630
+ if override_accepted:
1631
+ # Apply override
1632
+ self.token_manager.apply_override(additional_percentage)
1633
+ new_input_limit = self.token_manager.max_input_tokens + self.token_manager.current_input_override
1634
+ new_output_limit = self.token_manager.max_output_tokens + self.token_manager.current_output_override
1635
+ self.cli_interface.print_success(
1636
+ f"Token limit override applied: +{additional_percentage}% "
1637
+ f"(new limits: {new_input_limit:,} input, {new_output_limit:,} output tokens)"
1638
+ )
1639
+ # Continue with request
1640
+ else:
1641
+ # User declined override
1642
+ self.cli_interface.print_info("Request cancelled due to token limit")
1643
+ self._in_tool_use_loop = False
1644
+ self._check_and_perform_rollup()
1645
+ return "I apologise, but the token limit has been reached and I cannot process this request at this time. Please wait for the limit to reset or contact your administrator to increase the limit."
1646
+ else:
1647
+ # No override allowed
1648
+ self.cli_interface.print_info("Request cancelled due to token limit (no override allowed)")
1649
+ self._in_tool_use_loop = False
1650
+ self._check_and_perform_rollup()
1651
+ return "I apologise, but the token limit has been reached and I cannot process this request at this time. Please wait for the limit to reset."
1652
+ else:
1653
+ # No CLI interface, just log and return
1654
+ logging.warning("Budget limit reached, request blocked")
1655
+ self._in_tool_use_loop = False
1656
+ self._check_and_perform_rollup()
1657
+ return "Budget limit reached."
1658
+
1659
+ # Invoke the model with tools and combined instructions (global + conversation-specific)
1660
+ response = self.bedrock_service.invoke_model(
1661
+ messages,
1662
+ max_tokens=self.max_tokens,
1663
+ tools=filtered_tools,
1664
+ system=self._get_combined_instructions()
1665
+ )
1666
+
1667
+ if not response or response.get('error'):
1668
+ # Handle error response
1669
+ if response and response.get('error'):
1670
+ error_code = response.get('error_code', 'Unknown')
1671
+ error_message = response.get('error_message', 'No details available')
1672
+ error_type = response.get('error_type', 'Unknown')
1673
+ retries_attempted = response.get('retries_attempted', 0)
1674
+
1675
+ logging.error(f"Model invocation failed - Type: {error_type}, Code: {error_code}, Message: {error_message}")
1676
+ if retries_attempted > 0:
1677
+ logging.error(f"Failed after {retries_attempted} retry attempt(s)")
1678
+
1679
+ if self.cli_interface:
1680
+ self.cli_interface.print_separator("─")
1681
+ self.cli_interface.print_error(f"✗ Failed to get response from the model")
1682
+ if retries_attempted > 0:
1683
+ self.cli_interface.print_error(f"(Failed after {retries_attempted} retry attempt(s))")
1684
+ self.cli_interface.print_error(f"Error Code: {error_code}")
1685
+ self.cli_interface.print_error(f"Error Message: {error_message}")
1686
+
1687
+ # Provide helpful suggestions based on error type
1688
+ if 'ThrottlingException' in error_code or 'TooManyRequestsException' in error_code:
1689
+ self.cli_interface.print_info("💡 Suggestion: You're hitting rate limits. Wait a moment and try again.")
1690
+ elif 'ModelTimeoutException' in error_code or 'timeout' in error_message.lower():
1691
+ self.cli_interface.print_info("💡 Suggestion: The request timed out. Try simplifying your request or reducing conversation history.")
1692
+ elif 'ValidationException' in error_code:
1693
+ self.cli_interface.print_info("💡 Suggestion: There's an issue with the request format. Check your message content and tool configurations.")
1694
+ elif 'ModelNotReadyException' in error_code:
1695
+ self.cli_interface.print_info("💡 Suggestion: The model is not ready. Wait a moment and try again.")
1696
+ elif 'ServiceUnavailableException' in error_code or 'InternalServerError' in error_code:
1697
+ self.cli_interface.print_info("💡 Suggestion: AWS Bedrock service is experiencing issues. Wait a moment and try again.")
1698
+ elif 'AccessDeniedException' in error_code or 'UnauthorizedException' in error_code:
1699
+ self.cli_interface.print_info("💡 Suggestion: Check your AWS credentials and permissions for Bedrock access.")
1700
+ elif 'ModelStreamErrorException' in error_code:
1701
+ self.cli_interface.print_info("💡 Suggestion: There was an error in the model's response stream. Try again.")
1702
+ else:
1703
+ self.cli_interface.print_info("💡 Suggestion: Check the application logs for more details.")
1704
+
1705
+ self.cli_interface.print_separator("─")
1706
+ else:
1707
+ logging.error("Failed to get response from model - no response received")
1708
+ if self.cli_interface:
1709
+ self.cli_interface.print_error("✗ Failed to get response from the model (no response received)")
1710
+
1711
+ # Clear tool use flag even on failure
1712
+ self._in_tool_use_loop = False
1713
+ self._check_and_perform_rollup()
1714
+
1715
+ return None
1716
+
1717
+ # Track API token usage
1718
+ usage = response.get('usage', {})
1719
+ if usage:
1720
+ input_tokens = usage.get('input_tokens', 0)
1721
+ output_tokens = usage.get('output_tokens', 0)
1722
+ if input_tokens or output_tokens:
1723
+ # Record usage with model_id for per-model tracking
1724
+ model_id = self.bedrock_service.current_model_id
1725
+ self.database.update_token_usage(
1726
+ self.current_conversation_id,
1727
+ input_tokens,
1728
+ output_tokens,
1729
+ model_id
1730
+ )
1731
+ logging.debug(f"API usage: {input_tokens} input tokens, {output_tokens} output tokens (model: {model_id})")
1732
+
1733
+ # Token Management: Record actual usage for token tracking
1734
+ if self.token_manager:
1735
+ region = self.bedrock_service.bedrock_runtime_client.meta.region_name
1736
+ recorded_input, recorded_output = self.token_manager.record_usage(
1737
+ conversation_id=self.current_conversation_id,
1738
+ model_id=model_id,
1739
+ region=region,
1740
+ input_tokens=input_tokens,
1741
+ output_tokens=output_tokens
1742
+ )
1743
+ logging.debug(f"Token tracking: {recorded_input:,} input, {recorded_output:,} output tokens for this request")
1744
+
1745
+ # Check if model wants to use tools
1746
+ content_blocks = response.get('content_blocks', [])
1747
+ stop_reason = response.get('stop_reason')
1748
+ logging.debug(f"Model response: stop_reason={stop_reason}, content_blocks={len(content_blocks)}")
1749
+
1750
+ # Check for tool use
1751
+ tool_uses = [block for block in content_blocks if block.get('type') == 'tool_use']
1752
+
1753
+ if tool_uses and stop_reason == 'tool_use':
1754
+ # Model wants to use tools
1755
+ logging.info(f"Model requested {len(tool_uses)} tool call(s)")
1756
+
1757
+ # Store assistant's tool use message (with all content blocks)
1758
+ assistant_content_json = json.dumps(content_blocks)
1759
+ self.add_assistant_message(assistant_content_json)
1760
+ logging.debug(f"Stored assistant tool use message")
1761
+
1762
+ # Display any text blocks that appear with tool calls (e.g., "Let me check that...")
1763
+ if self.cli_interface:
1764
+ text_blocks = [block for block in content_blocks if block.get('type') == 'text']
1765
+ for text_block in text_blocks:
1766
+ text_content = text_block.get('text', '')
1767
+ if text_content:
1768
+ # Display as assistant message (not markdown since it's usually brief)
1769
+ self.cli_interface.console.print(f"[bold cyan]Assistant:[/bold cyan] {text_content}")
1770
+
1771
+ # Call each tool and collect results
1772
+ tool_results = []
1773
+ for tool_use in tool_uses:
1774
+ tool_name = tool_use.get('name')
1775
+ tool_input = tool_use.get('input', {})
1776
+ tool_id = tool_use.get('id')
1777
+
1778
+ # Check tool permission
1779
+ permission_allowed = self.database.is_tool_allowed(self.current_conversation_id, tool_name)
1780
+
1781
+ if permission_allowed is None:
1782
+ # First-time usage - check if auto-approve is enabled
1783
+ auto_approve = self.config.get('tool_permissions', {}).get('auto_approve', False)
1784
+
1785
+ if auto_approve:
1786
+ # Auto-approve enabled - allow this time without storing permission
1787
+ logging.info(f"Tool {tool_name} auto-approved (tool_permissions.auto_approve=true)")
1788
+ permission_response = 'once' # Allow but don't store in database
1789
+ else:
1790
+ # Prompt user for permission
1791
+ logging.info(f"First-time tool usage detected: {tool_name}, prompting user for permission")
1792
+
1793
+ # Get tool description from cache if available
1794
+ tool_description = None
1795
+ if self._tools_cache:
1796
+ for cached_tool in self._tools_cache:
1797
+ if cached_tool.get('name') == tool_name:
1798
+ input_schema = cached_tool.get('input_schema', {})
1799
+ tool_description = input_schema.get('description', cached_tool.get('description'))
1800
+ break
1801
+
1802
+ # Prompt user via the appropriate interface (Web takes priority over CLI)
1803
+ if hasattr(self, 'web_interface') and self.web_interface:
1804
+ # User is in web mode, prompt via web interface
1805
+ permission_response = self.web_interface.prompt_tool_permission(tool_name, tool_description)
1806
+ elif self.cli_interface:
1807
+ # User is in CLI mode, prompt via CLI interface
1808
+ permission_response = self.cli_interface.prompt_tool_permission(tool_name, tool_description)
1809
+ else:
1810
+ # No interface available, deny by default for security
1811
+ logging.warning(f"No interface available to prompt for tool permission, denying tool: {tool_name}")
1812
+ permission_response = 'denied'
1813
+
1814
+ if permission_response == 'once':
1815
+ # Allow this time only, don't store permission
1816
+ logging.info(f"Tool {tool_name} allowed for this use only")
1817
+ permission_allowed = True
1818
+ elif permission_response == 'allowed':
1819
+ # Store permission and proceed
1820
+ self.database.set_tool_permission(self.current_conversation_id, tool_name, PERMISSION_ALLOWED)
1821
+ logging.info(f"Tool {tool_name} permission granted for all future uses")
1822
+ permission_allowed = True
1823
+ elif permission_response == 'denied':
1824
+ # Store denial
1825
+ self.database.set_tool_permission(self.current_conversation_id, tool_name, PERMISSION_DENIED)
1826
+ logging.info(f"Tool {tool_name} permission denied")
1827
+ permission_allowed = False
1828
+ else:
1829
+ # Cancelled or error - skip this tool
1830
+ logging.info(f"Tool {tool_name} permission cancelled by user")
1831
+ permission_allowed = False
1832
+
1833
+ # If tool is denied, skip it and add error result
1834
+ if not permission_allowed:
1835
+ logging.warning(f"Tool {tool_name} denied by user permission settings, skipping")
1836
+ tool_results.append({
1837
+ 'type': 'tool_result',
1838
+ 'tool_use_id': tool_id,
1839
+ 'content': f"Error: Tool '{tool_name}' is not allowed. Permission was denied by user.",
1840
+ 'is_error': True
1841
+ })
1842
+ continue
1843
+
1844
+ # Track tool call for loop detection
1845
+ tool_call_history.append(tool_name)
1846
+ logging.info(f"Calling tool: {tool_name} (iteration {iteration})")
1847
+
1848
+ # Display tool call if CLI interface available
1849
+ if self.cli_interface:
1850
+ self.cli_interface.display_tool_call(tool_name, tool_input)
1851
+
1852
+ # Call the tool and get metrics
1853
+ result, execution_time_ms, is_error = self._call_mcp_tool(tool_name, tool_input, user_message)
1854
+
1855
+ # Find which server this tool belongs to
1856
+ tool_server = "unknown"
1857
+ if self._tools_cache:
1858
+ for cached_tool in self._tools_cache:
1859
+ if cached_tool.get('name') == tool_name:
1860
+ tool_server = cached_tool.get('server', 'unknown')
1861
+ break
1862
+
1863
+ # Record MCP transaction for security monitoring
1864
+ try:
1865
+ self.database.record_mcp_transaction(
1866
+ conversation_id=self.current_conversation_id,
1867
+ user_prompt=user_message,
1868
+ tool_name=tool_name,
1869
+ tool_server=tool_server,
1870
+ tool_input=json.dumps(tool_input),
1871
+ tool_response=result,
1872
+ is_error=is_error,
1873
+ execution_time_ms=execution_time_ms
1874
+ )
1875
+ except Exception as txn_err:
1876
+ logging.error(f"Failed to record MCP transaction: {txn_err}")
1877
+
1878
+ # Display tool result if CLI interface available
1879
+ if self.cli_interface:
1880
+ self.cli_interface.display_tool_result(tool_name, result, is_error)
1881
+
1882
+ # Truncate very large tool results to prevent token explosion
1883
+ # Most models can't handle more than ~200K tokens total
1884
+ result_tokens = self.bedrock_service.count_tokens(result)
1885
+
1886
+ if result_tokens > self.max_tool_result_tokens:
1887
+ # Truncate the result and add a warning
1888
+ truncated_result = result[:int(len(result) * (self.max_tool_result_tokens / result_tokens))]
1889
+ truncated_result += f"\n\n[Result truncated: {result_tokens} tokens reduced to ~{self.max_tool_result_tokens} tokens]"
1890
+ logging.warning(f"Tool {tool_name} result truncated from {result_tokens} to ~{self.max_tool_result_tokens} tokens")
1891
+ result = truncated_result
1892
+
1893
+ tool_results.append({
1894
+ 'type': 'tool_result',
1895
+ 'tool_use_id': tool_id,
1896
+ 'content': result
1897
+ })
1898
+
1899
+ # Add tool results as a user message
1900
+ tool_results_json = json.dumps(tool_results)
1901
+ self.add_user_message(f"[TOOL_RESULTS]{tool_results_json}")
1902
+
1903
+ # Continue loop to get model's next response
1904
+ continue
1905
+
1906
+ else:
1907
+ # Model gave a final answer (or incomplete response)
1908
+ assistant_message = self._extract_text_from_content(response.get('content', ''))
1909
+
1910
+ # Check if this looks like an incomplete response (model said it would do something but didn't)
1911
+ if assistant_message and stop_reason == 'max_tokens':
1912
+ logging.warning(f"Model response may be incomplete (stop_reason: max_tokens). "
1913
+ f"Response: {assistant_message[:100]}...")
1914
+ if self.cli_interface:
1915
+ suggested_max_tokens = self._calculate_suggested_max_tokens()
1916
+ self.cli_interface.print_separator("─")
1917
+ self.cli_interface.print_warning(
1918
+ "⚠️ Model response may be incomplete (hit token limit)."
1919
+ )
1920
+ self.cli_interface.print_info(
1921
+ f"Current max_tokens: {self.max_tokens:,}"
1922
+ )
1923
+ self.cli_interface.print_info(
1924
+ f"💡 Suggested max_tokens: {suggested_max_tokens:,}"
1925
+ )
1926
+
1927
+ # Prompt user if they want to increase max_tokens
1928
+ try:
1929
+ response = input(f"\nWould you like to increase max_tokens to {suggested_max_tokens:,} for this conversation? (y/n): ").strip().lower()
1930
+ if response == 'y' or response == 'yes':
1931
+ if self.update_conversation_max_tokens(suggested_max_tokens):
1932
+ self.cli_interface.print_success(
1933
+ f"✓ max_tokens increased to {suggested_max_tokens:,} for this conversation"
1934
+ )
1935
+ self.cli_interface.print_info(
1936
+ "This setting will be retained when you return to this conversation."
1937
+ )
1938
+ else:
1939
+ self.cli_interface.print_error(
1940
+ "Failed to update max_tokens. Please try again or modify config.yaml."
1941
+ )
1942
+ else:
1943
+ self.cli_interface.print_info(
1944
+ "max_tokens unchanged. Consider simplifying your request or manually adjusting in config.yaml."
1945
+ )
1946
+ except (EOFError, KeyboardInterrupt):
1947
+ # User cancelled or EOF
1948
+ self.cli_interface.print_info("\nmax_tokens unchanged.")
1949
+
1950
+ self.cli_interface.print_separator("─")
1951
+
1952
+ # Detect potential incomplete tool use (model says it will do something but no tool calls)
1953
+ intent_keywords = ['let me', "i'll", "i will", "now i", "now let me"]
1954
+ if assistant_message and not tool_uses and any(keyword in assistant_message.lower() for keyword in intent_keywords):
1955
+ logging.warning(f"Model indicated intent to act but made no tool calls. "
1956
+ f"Response: {assistant_message[:150]}...")
1957
+ if self.cli_interface and stop_reason != 'max_tokens':
1958
+ self.cli_interface.print_warning(
1959
+ "⚠️ Model indicated an action but didn't execute it. You may need to prompt again or rephrase your request."
1960
+ )
1961
+
1962
+ if assistant_message:
1963
+ # Add assistant's final response to conversation
1964
+ self.add_assistant_message(assistant_message)
1965
+
1966
+ # Detect if this is a synthesis/summary response that aggregates data
1967
+ # If so, prompt user to verify calculations to catch potential errors
1968
+ if self._detect_synthesis_response(assistant_message, tool_call_history):
1969
+ logging.info("Synthesis response detected - suggesting verification to user")
1970
+ if self.cli_interface:
1971
+ self.cli_interface.print_separator("─")
1972
+ self.cli_interface.print_warning(
1973
+ "📊 Synthesis/Summary Detected: This response aggregates data from multiple sources. "
1974
+ "To ensure accuracy, consider asking the assistant to verify its calculations "
1975
+ "by comparing with the detailed source data."
1976
+ )
1977
+ self.cli_interface.print_info(
1978
+ "💡 Suggested verification prompt: "
1979
+ "\"Please verify your calculations by reviewing the detailed reports and confirming all totals are accurate.\""
1980
+ )
1981
+ self.cli_interface.print_separator("─")
1982
+
1983
+ # Clear tool use flag and check for deferred rollup
1984
+ self._in_tool_use_loop = False
1985
+ self._check_and_perform_rollup()
1986
+
1987
+ return assistant_message
1988
+ else:
1989
+ logging.warning(f"Model returned empty response (stop_reason: {stop_reason})")
1990
+
1991
+ # Clear tool use flag even on empty response
1992
+ self._in_tool_use_loop = False
1993
+ self._check_and_perform_rollup()
1994
+
1995
+ return None
1996
+
1997
+ # Max iterations reached
1998
+ from collections import Counter
1999
+ tool_counts = Counter(tool_call_history)
2000
+ most_common = tool_counts.most_common(3)
2001
+ tool_summary = ', '.join([f"{name}({count})" for name, count in most_common])
2002
+
2003
+ logging.warning(f"Max tool iterations ({self.max_tool_iterations}) reached. "
2004
+ f"Tools called: {tool_summary}. Total calls: {len(tool_call_history)}")
2005
+
2006
+ # Clear tool use flag and check for deferred rollup
2007
+ self._in_tool_use_loop = False
2008
+ self._check_and_perform_rollup()
2009
+
2010
+ return (f"I apologise, but I've reached the maximum number of tool calls ({self.max_tool_iterations}) "
2011
+ f"for this request. I called {len(tool_call_history)} tools in total. "
2012
+ f"You may need to rephrase your request or break it into smaller tasks.")
2013
+
2014
+ def delete_current_conversation(self) -> bool:
2015
+ """
2016
+ Delete the current conversation.
2017
+
2018
+ Returns:
2019
+ True if successful, False otherwise
2020
+ """
2021
+ if not self.current_conversation_id:
2022
+ logging.warning("No conversation loaded to delete")
2023
+ return False
2024
+
2025
+ success = self.database.delete_conversation(self.current_conversation_id)
2026
+ if success:
2027
+ self.current_conversation_id = None
2028
+ logging.info("Current conversation deleted")
2029
+ return success
2030
+
2031
+ def attach_files(self, file_data: Union[List[str], List[Dict]]) -> bool:
2032
+ """
2033
+ Process and attach files to the current conversation.
2034
+
2035
+ Args:
2036
+ file_data: List of file paths (strings) or list of dicts with 'path' and 'tags' keys
2037
+
2038
+ Returns:
2039
+ True if all files attached successfully
2040
+ """
2041
+ if not self.current_conversation_id:
2042
+ logging.warning("No conversation loaded to attach files to")
2043
+ return False
2044
+
2045
+ from dtSpark.files.manager import FileManager
2046
+
2047
+ file_manager = FileManager(bedrock_service=self.bedrock_service)
2048
+ success_count = 0
2049
+
2050
+ # Normalize input to list of dicts format
2051
+ normalized_files = []
2052
+ if file_data and isinstance(file_data[0], str):
2053
+ # Old format: list of strings
2054
+ normalized_files = [{'path': fp, 'tags': None} for fp in file_data]
2055
+ else:
2056
+ # New format: list of dicts
2057
+ normalized_files = file_data
2058
+
2059
+ for file_info in normalized_files:
2060
+ file_path = file_info['path']
2061
+ tags = file_info.get('tags')
2062
+
2063
+ try:
2064
+ # Process the file
2065
+ result = file_manager.process_file(file_path)
2066
+
2067
+ if 'error' in result:
2068
+ logging.error(f"Failed to process file {file_path}: {result['error']}")
2069
+ continue
2070
+
2071
+ # Add to database
2072
+ file_id = self.database.add_file(
2073
+ conversation_id=self.current_conversation_id,
2074
+ filename=result['filename'],
2075
+ file_type=result['file_type'],
2076
+ file_size=result['file_size'],
2077
+ content_text=result.get('content_text'),
2078
+ content_base64=result.get('content_base64'),
2079
+ mime_type=result.get('mime_type'),
2080
+ token_count=result['token_count'],
2081
+ tags=tags
2082
+ )
2083
+
2084
+ tags_str = f" with tags '{tags}'" if tags else ""
2085
+ logging.info(f"Attached file {result['filename']} (ID: {file_id}, {result['token_count']} tokens{tags_str})")
2086
+ success_count += 1
2087
+
2088
+ except Exception as e:
2089
+ logging.error(f"Error attaching file {file_path}: {e}")
2090
+ continue
2091
+
2092
+ return success_count == len(normalized_files)
2093
+
2094
+ def attach_files_with_message(self, file_data: Union[List[str], List[Dict]]) -> bool:
2095
+ """
2096
+ Process and attach files to the current conversation, adding their content
2097
+ as a user message so the model can immediately access them.
2098
+
2099
+ Args:
2100
+ file_data: List of file paths (strings) or list of dicts with 'path' and 'tags' keys
2101
+
2102
+ Returns:
2103
+ True if all files attached successfully
2104
+ """
2105
+ if not self.current_conversation_id:
2106
+ logging.warning("No conversation loaded to attach files to")
2107
+ return False
2108
+
2109
+ from dtSpark.files.manager import FileManager
2110
+
2111
+ file_manager = FileManager(bedrock_service=self.bedrock_service)
2112
+ success_count = 0
2113
+ attached_file_info = []
2114
+
2115
+ # Normalize input to list of dicts format
2116
+ normalized_files = []
2117
+ if file_data and isinstance(file_data[0], str):
2118
+ # Old format: list of strings
2119
+ normalized_files = [{'path': fp, 'tags': None} for fp in file_data]
2120
+ else:
2121
+ # New format: list of dicts
2122
+ normalized_files = file_data
2123
+
2124
+ for file_info in normalized_files:
2125
+ file_path = file_info['path']
2126
+ tags = file_info.get('tags')
2127
+
2128
+ try:
2129
+ # Process the file
2130
+ result = file_manager.process_file(file_path)
2131
+
2132
+ if 'error' in result:
2133
+ logging.error(f"Failed to process file {file_path}: {result['error']}")
2134
+ continue
2135
+
2136
+ # Add to database
2137
+ file_id = self.database.add_file(
2138
+ conversation_id=self.current_conversation_id,
2139
+ filename=result['filename'],
2140
+ file_type=result['file_type'],
2141
+ file_size=result['file_size'],
2142
+ content_text=result.get('content_text'),
2143
+ content_base64=result.get('content_base64'),
2144
+ mime_type=result.get('mime_type'),
2145
+ token_count=result['token_count'],
2146
+ tags=tags
2147
+ )
2148
+
2149
+ tags_str = f" with tags '{tags}'" if tags else ""
2150
+ logging.info(f"Attached file {result['filename']} (ID: {file_id}, {result['token_count']} tokens{tags_str})")
2151
+
2152
+ # Store result with tags for message generation
2153
+ result['tags'] = tags
2154
+ attached_file_info.append(result)
2155
+ success_count += 1
2156
+
2157
+ except Exception as e:
2158
+ logging.error(f"Error attaching file {file_path}: {e}")
2159
+ continue
2160
+
2161
+ # If files were successfully attached, add their content as a user message
2162
+ if attached_file_info:
2163
+ context_parts = ["=== Newly Attached Files ===\n"]
2164
+
2165
+ for file_info in attached_file_info:
2166
+ # Include tags in file header if present
2167
+ tags_str = f" [Tags: {file_info.get('tags')}]" if file_info.get('tags') else ""
2168
+ context_parts.append(f"File: {file_info['filename']} ({file_info['file_type']}){tags_str}")
2169
+ context_parts.append("")
2170
+
2171
+ # Add text content if available
2172
+ if file_info.get('content_text'):
2173
+ context_parts.append(file_info['content_text'])
2174
+ context_parts.append("")
2175
+
2176
+ # For images, just note that they're attached
2177
+ elif file_info.get('content_base64'):
2178
+ context_parts.append(f"[Image file: {file_info.get('mime_type')}]")
2179
+ context_parts.append("")
2180
+
2181
+ context_parts.append("---")
2182
+ context_parts.append("")
2183
+
2184
+ context_parts.append("The above files have been attached to this conversation for reference.")
2185
+
2186
+ # Add as user message
2187
+ file_context_message = '\n'.join(context_parts)
2188
+ self.add_user_message(file_context_message)
2189
+ logging.info(f"Added file context message for {len(attached_file_info)} newly attached files")
2190
+
2191
+ return success_count == len(normalized_files)
2192
+
2193
+ def get_attached_files(self) -> List[Dict]:
2194
+ """
2195
+ Get all files attached to the current conversation.
2196
+
2197
+ Returns:
2198
+ List of file dictionaries
2199
+ """
2200
+ if not self.current_conversation_id:
2201
+ return []
2202
+
2203
+ return self.database.get_conversation_files(self.current_conversation_id)
2204
+
2205
+ def get_files_by_tag(self, tag: str) -> List[Dict]:
2206
+ """
2207
+ Get files attached to the current conversation filtered by tag.
2208
+
2209
+ Args:
2210
+ tag: Tag to filter by (case-insensitive)
2211
+
2212
+ Returns:
2213
+ List of file dictionaries with matching tag
2214
+ """
2215
+ if not self.current_conversation_id:
2216
+ return []
2217
+
2218
+ return self.database.get_files_by_tag(self.current_conversation_id, tag)
2219
+
2220
+ def _get_file_context(self) -> str:
2221
+ """
2222
+ Build context string from attached files.
2223
+
2224
+ Returns:
2225
+ Formatted string with file contents
2226
+ """
2227
+ files = self.get_attached_files()
2228
+ if not files:
2229
+ return ""
2230
+
2231
+ context_parts = ["=== Attached Files ===\n"]
2232
+
2233
+ for file_info in files:
2234
+ context_parts.append(f"File: {file_info['filename']} ({file_info['file_type']})")
2235
+ context_parts.append("")
2236
+
2237
+ # Add text content if available
2238
+ if file_info.get('content_text'):
2239
+ context_parts.append(file_info['content_text'])
2240
+ context_parts.append("")
2241
+
2242
+ # For images, just note that they're attached
2243
+ elif file_info.get('content_base64'):
2244
+ context_parts.append(f"[Image file: {file_info['mime_type']}]")
2245
+ context_parts.append("")
2246
+
2247
+ context_parts.append("---")
2248
+ context_parts.append("")
2249
+
2250
+ return '\n'.join(context_parts)
2251
+
2252
+ def export_conversation(self, file_path: str, format: str = 'markdown', include_tools: bool = True) -> bool:
2253
+ """
2254
+ Export the current conversation to a file in specified format.
2255
+
2256
+ Args:
2257
+ file_path: Path to save the file
2258
+ format: Export format ('markdown', 'html', 'csv')
2259
+ include_tools: Whether to include tool use details
2260
+
2261
+ Returns:
2262
+ True if successful, False otherwise
2263
+ """
2264
+ if format == 'markdown':
2265
+ return self._export_to_markdown(file_path, include_tools)
2266
+ elif format == 'html':
2267
+ return self._export_to_html(file_path, include_tools)
2268
+ elif format == 'csv':
2269
+ return self._export_to_csv(file_path, include_tools)
2270
+ else:
2271
+ logging.error(f"Unsupported export format: {format}")
2272
+ return False
2273
+
2274
+ def export_conversation_to_markdown(self, file_path: str) -> bool:
2275
+ """
2276
+ Export the current conversation to a markdown file (legacy method).
2277
+
2278
+ Args:
2279
+ file_path: Path to save the markdown file
2280
+
2281
+ Returns:
2282
+ True if successful, False otherwise
2283
+ """
2284
+ return self._export_to_markdown(file_path, include_tools=True)
2285
+
2286
+ def export_to_markdown(self, include_tool_details: bool = True) -> str:
2287
+ """
2288
+ Export the current conversation to markdown format and return as string.
2289
+
2290
+ This method is designed for web API use where content is returned rather
2291
+ than written to a file.
2292
+
2293
+ Args:
2294
+ include_tool_details: Whether to include tool use details
2295
+
2296
+ Returns:
2297
+ Markdown-formatted string of the conversation
2298
+ """
2299
+ return self._generate_markdown_content(include_tool_details)
2300
+
2301
+ def export_to_html(self, include_tool_details: bool = True) -> str:
2302
+ """
2303
+ Export the current conversation to HTML format and return as string.
2304
+
2305
+ This method is designed for web API use where content is returned rather
2306
+ than written to a file.
2307
+
2308
+ Args:
2309
+ include_tool_details: Whether to include tool use details
2310
+
2311
+ Returns:
2312
+ HTML-formatted string of the conversation
2313
+ """
2314
+ return self._generate_html_content(include_tool_details)
2315
+
2316
+ def export_to_csv(self, include_tool_details: bool = True) -> str:
2317
+ """
2318
+ Export the current conversation to CSV format and return as string.
2319
+
2320
+ This method is designed for web API use where content is returned rather
2321
+ than written to a file.
2322
+
2323
+ Args:
2324
+ include_tool_details: Whether to include tool use details
2325
+
2326
+ Returns:
2327
+ CSV-formatted string of the conversation
2328
+ """
2329
+ return self._generate_csv_content(include_tool_details)
2330
+
2331
+ def _generate_markdown_content(self, include_tools: bool = True) -> str:
2332
+ """
2333
+ Generate markdown-formatted content for the current conversation.
2334
+
2335
+ Args:
2336
+ include_tools: Whether to include tool use details
2337
+
2338
+ Returns:
2339
+ Markdown-formatted string of the conversation
2340
+ """
2341
+ if not self.current_conversation_id:
2342
+ logging.warning("No conversation loaded to export")
2343
+ return ""
2344
+
2345
+ # Get conversation info
2346
+ conv_info = self.get_current_conversation_info()
2347
+ if not conv_info:
2348
+ return ""
2349
+
2350
+ # Get messages (including rolled-up messages for complete history)
2351
+ messages = self.get_conversation_history(include_rolled_up=True)
2352
+
2353
+ # Build markdown content
2354
+ md_lines = []
2355
+
2356
+ # Header
2357
+ md_lines.append(f"# {conv_info['name']}")
2358
+ md_lines.append("")
2359
+ md_lines.append(f"**Model:** {conv_info['model_id']}")
2360
+ md_lines.append(f"**Created:** {conv_info['created_at']}")
2361
+ md_lines.append(f"**Last Updated:** {conv_info['last_updated']}")
2362
+ md_lines.append(f"**Total Tokens:** {conv_info['total_tokens']:,}")
2363
+ md_lines.append("")
2364
+
2365
+ # Include instructions if they exist
2366
+ if conv_info.get('instructions'):
2367
+ md_lines.append("## Instructions")
2368
+ md_lines.append("")
2369
+ md_lines.append(conv_info['instructions'])
2370
+ md_lines.append("")
2371
+
2372
+ # Include attached files if they exist
2373
+ attached_files = self.get_attached_files()
2374
+ if attached_files:
2375
+ md_lines.append("## Attached Files")
2376
+ md_lines.append("")
2377
+ for file_info in attached_files:
2378
+ size_kb = file_info['file_size'] / 1024
2379
+ md_lines.append(f"- **{file_info['filename']}** ({file_info['file_type']}, {size_kb:.1f} KB, {file_info['token_count']} tokens)")
2380
+ md_lines.append("")
2381
+
2382
+ md_lines.append("---")
2383
+ md_lines.append("")
2384
+
2385
+ # Messages
2386
+ for msg in messages:
2387
+ timestamp = datetime.fromisoformat(msg['timestamp'])
2388
+ role = msg['role'].capitalize()
2389
+ content = msg['content']
2390
+
2391
+ # Detect special message types
2392
+ is_rollup_summary = content.startswith('[Summary of previous conversation]')
2393
+ is_tool_result = content.startswith('[TOOL_RESULTS]')
2394
+
2395
+ # Check if this is a tool call message (assistant with tool_use blocks)
2396
+ is_tool_call = False
2397
+ if role.lower() == 'assistant':
2398
+ try:
2399
+ content_blocks = json.loads(content)
2400
+ if isinstance(content_blocks, list) and any(block.get('type') == 'tool_use' for block in content_blocks):
2401
+ is_tool_call = True
2402
+ except:
2403
+ pass
2404
+
2405
+ # Format role header based on message type
2406
+ if is_rollup_summary:
2407
+ md_lines.append(f"## 📋 Rollup Summary")
2408
+ elif is_tool_result:
2409
+ md_lines.append(f"## 🔧 Tool Results")
2410
+ elif is_tool_call:
2411
+ md_lines.append(f"## 🤖 Assistant (with Tool Calls)")
2412
+ elif role.lower() == 'user':
2413
+ md_lines.append(f"## 👤 {role}")
2414
+ elif role.lower() == 'assistant':
2415
+ md_lines.append(f"## 🤖 {role}")
2416
+ else:
2417
+ md_lines.append(f"## {role}")
2418
+
2419
+ md_lines.append(f"*{timestamp.strftime('%Y-%m-%d %H:%M:%S')}*")
2420
+ md_lines.append("")
2421
+
2422
+ # Clean up content if it's tool-related
2423
+ if content.startswith('[TOOL_RESULTS]') and include_tools:
2424
+ # Parse and format tool results
2425
+ try:
2426
+ tool_results_json = content.replace('[TOOL_RESULTS]', '', 1)
2427
+ tool_results = json.loads(tool_results_json)
2428
+ md_lines.append("**Tool Results:**")
2429
+ md_lines.append("")
2430
+ for result in tool_results:
2431
+ md_lines.append(f"- Tool: `{result.get('tool_use_id', 'unknown')}`")
2432
+ md_lines.append(f" Result: {result.get('content', '')}")
2433
+ md_lines.append("")
2434
+ except:
2435
+ md_lines.append(content)
2436
+ md_lines.append("")
2437
+ elif content.startswith('[TOOL_RESULTS]') and not include_tools:
2438
+ # Skip tool results if not including tools
2439
+ md_lines.append("*[Tool execution details omitted]*")
2440
+ md_lines.append("")
2441
+ elif content.startswith('['):
2442
+ # Try to parse as JSON (tool use blocks)
2443
+ try:
2444
+ content_blocks = json.loads(content)
2445
+ for block in content_blocks:
2446
+ if block.get('type') == 'text':
2447
+ md_lines.append(block.get('text', ''))
2448
+ md_lines.append("")
2449
+ elif block.get('type') == 'tool_use' and include_tools:
2450
+ md_lines.append(f"**Tool Call:** `{block.get('name')}`")
2451
+ md_lines.append(f"**Input:** {json.dumps(block.get('input', {}), indent=2)}")
2452
+ md_lines.append("")
2453
+ except:
2454
+ md_lines.append(content)
2455
+ md_lines.append("")
2456
+ else:
2457
+ md_lines.append(content)
2458
+ md_lines.append("")
2459
+
2460
+ md_lines.append("---")
2461
+ md_lines.append("")
2462
+
2463
+ # Return the joined content
2464
+ return '\n'.join(md_lines)
2465
+
2466
+ def _export_to_markdown(self, file_path: str, include_tools: bool = True) -> bool:
2467
+ """
2468
+ Export the current conversation to a markdown file.
2469
+
2470
+ Args:
2471
+ file_path: Path to save the markdown file
2472
+ include_tools: Whether to include tool use details
2473
+
2474
+ Returns:
2475
+ True if successful, False otherwise
2476
+ """
2477
+ try:
2478
+ content = self._generate_markdown_content(include_tools)
2479
+ if not content:
2480
+ return False
2481
+
2482
+ # Write to file
2483
+ with open(file_path, 'w', encoding='utf-8') as f:
2484
+ f.write(content)
2485
+
2486
+ logging.info(f"Exported conversation to {file_path}")
2487
+ return True
2488
+
2489
+ except Exception as e:
2490
+ logging.error(f"Failed to export conversation: {e}")
2491
+ return False
2492
+
2493
+ def _generate_html_content(self, include_tools: bool = True) -> str:
2494
+ """
2495
+ Generate HTML-formatted content for the current conversation.
2496
+
2497
+ Args:
2498
+ include_tools: Whether to include tool use details
2499
+
2500
+ Returns:
2501
+ HTML-formatted string of the conversation
2502
+ """
2503
+ import tempfile
2504
+ import os
2505
+
2506
+ # Use temporary file approach to reuse existing export logic
2507
+ try:
2508
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False, encoding='utf-8') as tmp:
2509
+ tmp_path = tmp.name
2510
+
2511
+ # Export to temporary file
2512
+ success = self._export_to_html(tmp_path, include_tools)
2513
+ if not success:
2514
+ return ""
2515
+
2516
+ # Read content back
2517
+ with open(tmp_path, 'r', encoding='utf-8') as f:
2518
+ content = f.read()
2519
+
2520
+ # Clean up temporary file
2521
+ os.unlink(tmp_path)
2522
+
2523
+ return content
2524
+
2525
+ except Exception as e:
2526
+ logging.error(f"Failed to generate HTML content: {e}")
2527
+ return ""
2528
+
2529
+ def _generate_csv_content(self, include_tools: bool = True) -> str:
2530
+ """
2531
+ Generate CSV-formatted content for the current conversation.
2532
+
2533
+ Args:
2534
+ include_tools: Whether to include tool use details
2535
+
2536
+ Returns:
2537
+ CSV-formatted string of the conversation
2538
+ """
2539
+ import tempfile
2540
+ import os
2541
+
2542
+ # Use temporary file approach to reuse existing export logic
2543
+ try:
2544
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False, encoding='utf-8') as tmp:
2545
+ tmp_path = tmp.name
2546
+
2547
+ # Export to temporary file
2548
+ success = self._export_to_csv(tmp_path, include_tools)
2549
+ if not success:
2550
+ return ""
2551
+
2552
+ # Read content back
2553
+ with open(tmp_path, 'r', encoding='utf-8') as f:
2554
+ content = f.read()
2555
+
2556
+ # Clean up temporary file
2557
+ os.unlink(tmp_path)
2558
+
2559
+ return content
2560
+
2561
+ except Exception as e:
2562
+ logging.error(f"Failed to generate CSV content: {e}")
2563
+ return ""
2564
+
2565
+ def _export_to_html(self, file_path: str, include_tools: bool = True) -> bool:
2566
+ """
2567
+ Export the current conversation to an HTML file with chat styling.
2568
+
2569
+ Args:
2570
+ file_path: Path to save the HTML file
2571
+ include_tools: Whether to include tool use details
2572
+
2573
+ Returns:
2574
+ True if successful, False otherwise
2575
+ """
2576
+ if not self.current_conversation_id:
2577
+ logging.warning("No conversation loaded to export")
2578
+ return False
2579
+
2580
+ try:
2581
+ # Get conversation info
2582
+ conv_info = self.get_current_conversation_info()
2583
+ if not conv_info:
2584
+ return False
2585
+
2586
+ # Get messages (including rolled-up messages for complete history)
2587
+ messages = self.get_conversation_history(include_rolled_up=True)
2588
+
2589
+ # Build HTML content
2590
+ html_parts = []
2591
+
2592
+ # HTML header with styling
2593
+ html_parts.append('''<!DOCTYPE html>
2594
+ <html lang="en">
2595
+ <head>
2596
+ <meta charset="UTF-8">
2597
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2598
+ <title>''' + conv_info['name'] + '''</title>
2599
+ <style>
2600
+ * {
2601
+ margin: 0;
2602
+ padding: 0;
2603
+ box-sizing: border-box;
2604
+ }
2605
+ body {
2606
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
2607
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
2608
+ padding: 20px;
2609
+ line-height: 1.6;
2610
+ }
2611
+ .container {
2612
+ max-width: 900px;
2613
+ margin: 0 auto;
2614
+ background: white;
2615
+ border-radius: 12px;
2616
+ box-shadow: 0 10px 40px rgba(0,0,0,0.1);
2617
+ overflow: hidden;
2618
+ }
2619
+ .header {
2620
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
2621
+ color: white;
2622
+ padding: 30px;
2623
+ text-align: center;
2624
+ }
2625
+ .header h1 {
2626
+ font-size: 28px;
2627
+ margin-bottom: 10px;
2628
+ }
2629
+ .header .metadata {
2630
+ font-size: 14px;
2631
+ opacity: 0.9;
2632
+ }
2633
+ .chat-container {
2634
+ padding: 20px;
2635
+ max-height: 80vh;
2636
+ overflow-y: auto;
2637
+ }
2638
+ .message {
2639
+ margin-bottom: 20px;
2640
+ animation: fadeIn 0.3s ease-in;
2641
+ }
2642
+ @keyframes fadeIn {
2643
+ from { opacity: 0; transform: translateY(10px); }
2644
+ to { opacity: 1; transform: translateY(0); }
2645
+ }
2646
+ .message-user {
2647
+ display: flex;
2648
+ justify-content: flex-end;
2649
+ }
2650
+ .message-assistant {
2651
+ display: flex;
2652
+ justify-content: flex-start;
2653
+ }
2654
+ .message-bubble {
2655
+ max-width: 70%;
2656
+ padding: 15px 20px;
2657
+ border-radius: 18px;
2658
+ position: relative;
2659
+ }
2660
+ .message-user .message-bubble {
2661
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
2662
+ color: white;
2663
+ }
2664
+ .message-assistant .message-bubble {
2665
+ background: #f0f0f0;
2666
+ color: #333;
2667
+ }
2668
+ .message-tool-result {
2669
+ display: flex;
2670
+ justify-content: center;
2671
+ }
2672
+ .message-tool-result .message-bubble {
2673
+ background: #fff9e6;
2674
+ color: #333;
2675
+ border: 1px solid #ffc107;
2676
+ max-width: 85%;
2677
+ }
2678
+ .message-rollup {
2679
+ display: flex;
2680
+ justify-content: center;
2681
+ }
2682
+ .message-rollup .message-bubble {
2683
+ background: #e8f5e9;
2684
+ color: #333;
2685
+ border: 1px solid #4caf50;
2686
+ max-width: 85%;
2687
+ font-style: italic;
2688
+ }
2689
+ .message-tool-call {
2690
+ display: flex;
2691
+ justify-content: flex-start;
2692
+ }
2693
+ .message-tool-call .message-bubble {
2694
+ background: #e3f2fd;
2695
+ color: #333;
2696
+ border: 1px solid #2196f3;
2697
+ }
2698
+ .message-role {
2699
+ font-weight: bold;
2700
+ font-size: 12px;
2701
+ margin-bottom: 5px;
2702
+ opacity: 0.8;
2703
+ }
2704
+ .message-timestamp {
2705
+ font-size: 11px;
2706
+ opacity: 0.6;
2707
+ margin-top: 5px;
2708
+ }
2709
+ .message-content {
2710
+ white-space: pre-wrap;
2711
+ word-wrap: break-word;
2712
+ }
2713
+ .tool-section {
2714
+ background: transparent;
2715
+ border-left: none;
2716
+ padding: 0;
2717
+ margin-top: 0;
2718
+ border-radius: 0;
2719
+ font-size: 13px;
2720
+ }
2721
+ .tool-section .tool-title {
2722
+ font-weight: bold;
2723
+ margin-bottom: 10px;
2724
+ color: #f57c00;
2725
+ }
2726
+ .tool-call {
2727
+ background: #e3f2fd;
2728
+ border-left: 4px solid #2196f3;
2729
+ padding: 10px;
2730
+ margin-top: 10px;
2731
+ border-radius: 4px;
2732
+ font-size: 13px;
2733
+ }
2734
+ .tool-title {
2735
+ font-weight: bold;
2736
+ margin-bottom: 5px;
2737
+ }
2738
+ code {
2739
+ background: rgba(0,0,0,0.05);
2740
+ padding: 2px 6px;
2741
+ border-radius: 3px;
2742
+ font-family: 'Courier New', monospace;
2743
+ font-size: 13px;
2744
+ }
2745
+ pre {
2746
+ background: rgba(0,0,0,0.05);
2747
+ padding: 10px;
2748
+ border-radius: 4px;
2749
+ overflow-x: auto;
2750
+ margin: 10px 0;
2751
+ }
2752
+ .info-section {
2753
+ background: #f8f9fa;
2754
+ padding: 20px;
2755
+ border-top: 1px solid #dee2e6;
2756
+ }
2757
+ .info-section h3 {
2758
+ margin-bottom: 10px;
2759
+ color: #667eea;
2760
+ }
2761
+ .info-section ul {
2762
+ list-style: none;
2763
+ }
2764
+ .info-section li {
2765
+ padding: 5px 0;
2766
+ }
2767
+ </style>
2768
+ </head>
2769
+ <body>
2770
+ <div class="container">
2771
+ <div class="header">
2772
+ <h1>''' + conv_info['name'] + '''</h1>
2773
+ <div class="metadata">
2774
+ <div>Model: ''' + conv_info['model_id'] + '''</div>
2775
+ <div>Created: ''' + conv_info['created_at'] + '''</div>
2776
+ <div>Total Tokens: ''' + f"{conv_info['total_tokens']:,}" + '''</div>
2777
+ </div>
2778
+ </div>
2779
+ ''')
2780
+
2781
+ # Instructions section if exists
2782
+ if conv_info.get('instructions'):
2783
+ html_parts.append('''
2784
+ <div class="info-section">
2785
+ <h3>Instructions</h3>
2786
+ <p>''' + conv_info['instructions'].replace('\n', '<br>') + '''</p>
2787
+ </div>
2788
+ ''')
2789
+
2790
+ # Attached files if exist
2791
+ attached_files = self.get_attached_files()
2792
+ if attached_files:
2793
+ html_parts.append('''
2794
+ <div class="info-section">
2795
+ <h3>Attached Files</h3>
2796
+ <ul>
2797
+ ''')
2798
+ for file_info in attached_files:
2799
+ size_kb = file_info['file_size'] / 1024
2800
+ html_parts.append(f''' <li><strong>{file_info['filename']}</strong> ({file_info['file_type']}, {size_kb:.1f} KB, {file_info['token_count']} tokens)</li>
2801
+ ''')
2802
+ html_parts.append(''' </ul>
2803
+ </div>
2804
+ ''')
2805
+
2806
+ # Chat messages
2807
+ html_parts.append('''
2808
+ <div class="chat-container">
2809
+ ''')
2810
+
2811
+ for msg in messages:
2812
+ timestamp = datetime.fromisoformat(msg['timestamp'])
2813
+ role = msg['role'].capitalize()
2814
+ content = msg['content']
2815
+
2816
+ # Detect special message types
2817
+ is_rollup_summary = content.startswith('[Summary of previous conversation]')
2818
+ is_tool_result = content.startswith('[TOOL_RESULTS]')
2819
+
2820
+ # Check if this is a tool call message (assistant with tool_use blocks)
2821
+ is_tool_call = False
2822
+ if role.lower() == 'assistant' and not is_tool_result:
2823
+ try:
2824
+ content_blocks = json.loads(content)
2825
+ if isinstance(content_blocks, list) and any(block.get('type') == 'tool_use' for block in content_blocks):
2826
+ is_tool_call = True
2827
+ except:
2828
+ pass
2829
+
2830
+ # Assign message class and labels based on type
2831
+ if is_rollup_summary:
2832
+ message_class = "message-rollup"
2833
+ role_icon = "📋"
2834
+ role_label = "Rollup Summary"
2835
+ elif is_tool_result:
2836
+ message_class = "message-tool-result"
2837
+ role_icon = "🔧"
2838
+ role_label = "Tool Results"
2839
+ elif is_tool_call:
2840
+ message_class = "message-tool-call"
2841
+ role_icon = "🛠️"
2842
+ role_label = "Assistant (Tool Calls)"
2843
+ else:
2844
+ message_class = f"message-{msg['role']}"
2845
+ role_icon = '👤 ' if role == 'User' else '🤖 '
2846
+ role_label = role
2847
+
2848
+ html_parts.append(f'''
2849
+ <div class="message {message_class}">
2850
+ <div class="message-bubble">
2851
+ <div class="message-role">{role_icon}{role_label}</div>
2852
+ <div class="message-timestamp">{timestamp.strftime('%Y-%m-%d %H:%M:%S')}</div>
2853
+ <div class="message-content">
2854
+ ''')
2855
+
2856
+ # Process content
2857
+ if content.startswith('[TOOL_RESULTS]') and include_tools:
2858
+ try:
2859
+ tool_results_json = content.replace('[TOOL_RESULTS]', '', 1)
2860
+ tool_results = json.loads(tool_results_json)
2861
+ html_parts.append(''' <div class="tool-section">
2862
+ <div class="tool-title">🔧 Tool Results:</div>
2863
+ ''')
2864
+ for idx, result in enumerate(tool_results, 1):
2865
+ result_content = result.get('content', '')
2866
+ # Truncate very long results for display
2867
+ if len(result_content) > 500:
2868
+ result_content = result_content[:500] + '... [truncated]'
2869
+ html_parts.append(f''' <div style="margin-bottom: 15px; padding: 10px; background: rgba(255,255,255,0.5); border-radius: 4px;">
2870
+ <div style="font-weight: bold; color: #f57c00; margin-bottom: 5px;">Result {idx}</div>
2871
+ <div style="font-size: 12px; color: #666; margin-bottom: 5px;">Tool ID: <code style="background: rgba(0,0,0,0.05); padding: 2px 4px; border-radius: 2px;">{result.get('tool_use_id', 'unknown')}</code></div>
2872
+ <div style="white-space: pre-wrap; word-wrap: break-word;">{result_content.replace('<', '&lt;').replace('>', '&gt;')}</div>
2873
+ </div>
2874
+ ''')
2875
+ html_parts.append(''' </div>
2876
+ ''')
2877
+ except:
2878
+ html_parts.append(f''' {content.replace('<', '&lt;').replace('>', '&gt;')}
2879
+ ''')
2880
+ elif content.startswith('[TOOL_RESULTS]') and not include_tools:
2881
+ html_parts.append(''' <em>[Tool execution details omitted]</em>
2882
+ ''')
2883
+ elif content.startswith('['):
2884
+ try:
2885
+ content_blocks = json.loads(content)
2886
+ for block in content_blocks:
2887
+ if block.get('type') == 'text':
2888
+ html_parts.append(f''' {block.get('text', '').replace('<', '&lt;').replace('>', '&gt;')}
2889
+ ''')
2890
+ elif block.get('type') == 'tool_use' and include_tools:
2891
+ html_parts.append(f''' <div class="tool-call">
2892
+ <div class="tool-title">Tool Call: <code>{block.get('name')}</code></div>
2893
+ <pre>{json.dumps(block.get('input', {}), indent=2)}</pre>
2894
+ </div>
2895
+ ''')
2896
+ except:
2897
+ html_parts.append(f''' {content.replace('<', '&lt;').replace('>', '&gt;')}
2898
+ ''')
2899
+ else:
2900
+ escaped_content = content.replace('<', '&lt;').replace('>', '&gt;').replace('\n', '<br>')
2901
+ html_parts.append(f''' {escaped_content}
2902
+ ''')
2903
+
2904
+ html_parts.append(''' </div>
2905
+ </div>
2906
+ </div>
2907
+ ''')
2908
+
2909
+ html_parts.append('''
2910
+ </div>
2911
+ </div>
2912
+ </body>
2913
+ </html>
2914
+ ''')
2915
+
2916
+ # Write to file
2917
+ with open(file_path, 'w', encoding='utf-8') as f:
2918
+ f.write(''.join(html_parts))
2919
+
2920
+ logging.info(f"Exported conversation to HTML: {file_path}")
2921
+ return True
2922
+
2923
+ except Exception as e:
2924
+ logging.error(f"Failed to export conversation to HTML: {e}")
2925
+ return False
2926
+
2927
+ def _export_to_csv(self, file_path: str, include_tools: bool = True) -> bool:
2928
+ """
2929
+ Export the current conversation to a CSV file.
2930
+
2931
+ Args:
2932
+ file_path: Path to save the CSV file
2933
+ include_tools: Whether to include tool use details
2934
+
2935
+ Returns:
2936
+ True if successful, False otherwise
2937
+ """
2938
+ if not self.current_conversation_id:
2939
+ logging.warning("No conversation loaded to export")
2940
+ return False
2941
+
2942
+ try:
2943
+ import csv
2944
+
2945
+ # Get conversation info
2946
+ conv_info = self.get_current_conversation_info()
2947
+ if not conv_info:
2948
+ return False
2949
+
2950
+ # Get messages (including rolled-up messages for complete history)
2951
+ messages = self.get_conversation_history(include_rolled_up=True)
2952
+
2953
+ with open(file_path, 'w', newline='', encoding='utf-8') as csvfile:
2954
+ fieldnames = ['Timestamp', 'Type', 'Role', 'Content', 'Token Count']
2955
+ writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
2956
+
2957
+ # Write header
2958
+ writer.writeheader()
2959
+
2960
+ # Write metadata as comments
2961
+ writer.writerow({
2962
+ 'Timestamp': 'METADATA',
2963
+ 'Type': '',
2964
+ 'Role': 'Conversation',
2965
+ 'Content': conv_info['name'],
2966
+ 'Token Count': ''
2967
+ })
2968
+ writer.writerow({
2969
+ 'Timestamp': 'METADATA',
2970
+ 'Type': '',
2971
+ 'Role': 'Model',
2972
+ 'Content': conv_info['model_id'],
2973
+ 'Token Count': ''
2974
+ })
2975
+ writer.writerow({
2976
+ 'Timestamp': 'METADATA',
2977
+ 'Type': '',
2978
+ 'Role': 'Total Tokens',
2979
+ 'Content': str(conv_info['total_tokens']),
2980
+ 'Token Count': ''
2981
+ })
2982
+
2983
+ # Write messages
2984
+ for msg in messages:
2985
+ timestamp = datetime.fromisoformat(msg['timestamp']).strftime('%Y-%m-%d %H:%M:%S')
2986
+ role = msg['role'].capitalize()
2987
+ content = msg['content']
2988
+
2989
+ # Detect special message types
2990
+ is_rollup_summary = content.startswith('[Summary of previous conversation]')
2991
+ is_tool_result = content.startswith('[TOOL_RESULTS]')
2992
+
2993
+ # Check if this is a tool call message
2994
+ is_tool_call = False
2995
+ if role.lower() == 'assistant' and not is_tool_result:
2996
+ try:
2997
+ content_blocks = json.loads(content)
2998
+ if isinstance(content_blocks, list) and any(block.get('type') == 'tool_use' for block in content_blocks):
2999
+ is_tool_call = True
3000
+ except:
3001
+ pass
3002
+
3003
+ # Assign type label
3004
+ if is_rollup_summary:
3005
+ msg_type = 'Rollup Summary'
3006
+ elif is_tool_result:
3007
+ msg_type = 'Tool Results'
3008
+ elif is_tool_call:
3009
+ msg_type = 'Tool Call'
3010
+ else:
3011
+ msg_type = 'Message'
3012
+
3013
+ # Process tool-related content
3014
+ if content.startswith('[TOOL_RESULTS]'):
3015
+ if include_tools:
3016
+ try:
3017
+ tool_results_json = content.replace('[TOOL_RESULTS]', '', 1)
3018
+ tool_results = json.loads(tool_results_json)
3019
+ content = f"Tool Results: {json.dumps(tool_results, indent=2)}"
3020
+ except:
3021
+ pass
3022
+ else:
3023
+ content = "[Tool execution details omitted]"
3024
+ elif content.startswith('['):
3025
+ try:
3026
+ content_blocks = json.loads(content)
3027
+ text_parts = []
3028
+ for block in content_blocks:
3029
+ if block.get('type') == 'text':
3030
+ text_parts.append(block.get('text', ''))
3031
+ elif block.get('type') == 'tool_use' and include_tools:
3032
+ text_parts.append(f"Tool Call: {block.get('name')} - Input: {json.dumps(block.get('input', {}))}")
3033
+ content = '\n'.join(text_parts) if text_parts else content
3034
+ except:
3035
+ pass
3036
+
3037
+ writer.writerow({
3038
+ 'Timestamp': timestamp,
3039
+ 'Type': msg_type,
3040
+ 'Role': role,
3041
+ 'Content': content,
3042
+ 'Token Count': msg.get('token_count', '')
3043
+ })
3044
+
3045
+ logging.info(f"Exported conversation to CSV: {file_path}")
3046
+ return True
3047
+
3048
+ except Exception as e:
3049
+ logging.error(f"Failed to export conversation to CSV: {e}")
3050
+ return False