tsugite-cli 0.3.3__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 (101) hide show
  1. tsugite/__init__.py +6 -0
  2. tsugite/agent_composition.py +163 -0
  3. tsugite/agent_inheritance.py +479 -0
  4. tsugite/agent_preparation.py +236 -0
  5. tsugite/agent_runner/__init__.py +45 -0
  6. tsugite/agent_runner/helpers.py +106 -0
  7. tsugite/agent_runner/history_integration.py +248 -0
  8. tsugite/agent_runner/metrics.py +100 -0
  9. tsugite/agent_runner/runner.py +1879 -0
  10. tsugite/agent_runner/validation.py +70 -0
  11. tsugite/agent_utils.py +167 -0
  12. tsugite/attachments/__init__.py +65 -0
  13. tsugite/attachments/auto_context.py +199 -0
  14. tsugite/attachments/base.py +34 -0
  15. tsugite/attachments/file.py +51 -0
  16. tsugite/attachments/inline.py +31 -0
  17. tsugite/attachments/storage.py +178 -0
  18. tsugite/attachments/url.py +59 -0
  19. tsugite/attachments/youtube.py +101 -0
  20. tsugite/benchmark/__init__.py +62 -0
  21. tsugite/benchmark/config.py +183 -0
  22. tsugite/benchmark/core.py +292 -0
  23. tsugite/benchmark/discovery.py +377 -0
  24. tsugite/benchmark/evaluators.py +671 -0
  25. tsugite/benchmark/execution.py +657 -0
  26. tsugite/benchmark/metrics.py +204 -0
  27. tsugite/benchmark/reports.py +420 -0
  28. tsugite/benchmark/utils.py +288 -0
  29. tsugite/builtin_agents/chat-assistant.md +53 -0
  30. tsugite/builtin_agents/default.md +140 -0
  31. tsugite/builtin_agents.py +5 -0
  32. tsugite/cache.py +195 -0
  33. tsugite/cli/__init__.py +1042 -0
  34. tsugite/cli/agents.py +148 -0
  35. tsugite/cli/attachments.py +193 -0
  36. tsugite/cli/benchmark.py +663 -0
  37. tsugite/cli/cache.py +113 -0
  38. tsugite/cli/config.py +272 -0
  39. tsugite/cli/helpers.py +534 -0
  40. tsugite/cli/history.py +193 -0
  41. tsugite/cli/init.py +387 -0
  42. tsugite/cli/mcp.py +193 -0
  43. tsugite/cli/tools.py +419 -0
  44. tsugite/config.py +204 -0
  45. tsugite/console.py +48 -0
  46. tsugite/constants.py +21 -0
  47. tsugite/core/__init__.py +19 -0
  48. tsugite/core/agent.py +774 -0
  49. tsugite/core/executor.py +300 -0
  50. tsugite/core/memory.py +67 -0
  51. tsugite/core/tools.py +271 -0
  52. tsugite/docker_cli.py +270 -0
  53. tsugite/events/__init__.py +55 -0
  54. tsugite/events/base.py +46 -0
  55. tsugite/events/bus.py +62 -0
  56. tsugite/events/events.py +224 -0
  57. tsugite/exceptions.py +40 -0
  58. tsugite/history/__init__.py +29 -0
  59. tsugite/history/index.py +210 -0
  60. tsugite/history/models.py +106 -0
  61. tsugite/history/storage.py +157 -0
  62. tsugite/mcp_client.py +219 -0
  63. tsugite/mcp_config.py +174 -0
  64. tsugite/md_agents.py +751 -0
  65. tsugite/models.py +257 -0
  66. tsugite/renderer.py +151 -0
  67. tsugite/shell_tool_config.py +265 -0
  68. tsugite/templates/assistant.md +14 -0
  69. tsugite/tools/__init__.py +265 -0
  70. tsugite/tools/agents.py +312 -0
  71. tsugite/tools/edit_strategies.py +393 -0
  72. tsugite/tools/fs.py +329 -0
  73. tsugite/tools/http.py +239 -0
  74. tsugite/tools/interactive.py +430 -0
  75. tsugite/tools/shell.py +129 -0
  76. tsugite/tools/shell_tools.py +214 -0
  77. tsugite/tools/tasks.py +339 -0
  78. tsugite/tsugite.py +7 -0
  79. tsugite/ui/__init__.py +46 -0
  80. tsugite/ui/base.py +638 -0
  81. tsugite/ui/chat.py +265 -0
  82. tsugite/ui/chat.tcss +92 -0
  83. tsugite/ui/chat_history.py +286 -0
  84. tsugite/ui/helpers.py +102 -0
  85. tsugite/ui/jsonl.py +125 -0
  86. tsugite/ui/live_template.py +529 -0
  87. tsugite/ui/plain.py +419 -0
  88. tsugite/ui/textual_chat.py +642 -0
  89. tsugite/ui/textual_handler.py +225 -0
  90. tsugite/ui/widgets/__init__.py +6 -0
  91. tsugite/ui/widgets/base_scroll_log.py +27 -0
  92. tsugite/ui/widgets/message_list.py +121 -0
  93. tsugite/ui/widgets/thought_log.py +80 -0
  94. tsugite/ui_context.py +90 -0
  95. tsugite/utils.py +367 -0
  96. tsugite/xdg.py +104 -0
  97. tsugite_cli-0.3.3.dist-info/METADATA +325 -0
  98. tsugite_cli-0.3.3.dist-info/RECORD +101 -0
  99. tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
  100. tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
  101. tsugite_cli-0.3.3.dist-info/licenses/LICENSE +235 -0
tsugite/ui/chat.py ADDED
@@ -0,0 +1,265 @@
1
+ """Chat session management for interactive conversations with agents."""
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+ from tsugite.config import load_config
11
+ from tsugite.md_agents import parse_agent_file
12
+ from tsugite.ui import CustomUILogger
13
+
14
+
15
+ class ChatTurn(BaseModel):
16
+ """Represents one turn in a conversation."""
17
+
18
+ model_config = ConfigDict(
19
+ extra="forbid",
20
+ )
21
+
22
+ timestamp: datetime
23
+ user_message: str
24
+ agent_response: str
25
+ tool_calls: List[str] = Field(default_factory=list)
26
+ token_count: Optional[int] = None
27
+ cost: Optional[float] = None
28
+
29
+
30
+ class ChatManager:
31
+ """Manages chat sessions with conversation history."""
32
+
33
+ def __init__(
34
+ self,
35
+ agent_path: Path,
36
+ model_override: Optional[str] = None,
37
+ max_history: int = 50,
38
+ custom_logger: Optional[CustomUILogger] = None,
39
+ stream: bool = False,
40
+ disable_history: bool = False,
41
+ resume_conversation_id: Optional[str] = None,
42
+ ):
43
+ """Initialize chat manager.
44
+
45
+ Args:
46
+ agent_path: Path to agent markdown file
47
+ model_override: Override agent's default model
48
+ max_history: Maximum turns to keep in context
49
+ custom_logger: Optional custom logger for UI
50
+ stream: Whether to stream responses in real-time
51
+ disable_history: Disable conversation history persistence
52
+ resume_conversation_id: Optional conversation ID to resume (skips auto-generation)
53
+ """
54
+ self.agent_path = agent_path
55
+ self.model_override = model_override
56
+ self.max_history = max_history
57
+ self.custom_logger = custom_logger
58
+ self.stream = stream
59
+ self.conversation_history: List[ChatTurn] = []
60
+ self.session_start = datetime.now()
61
+
62
+ # History support
63
+ self.conversation_id: Optional[str] = resume_conversation_id
64
+ config = load_config()
65
+ history_enabled = getattr(config, "history_enabled", True) and not disable_history
66
+
67
+ # Only create new conversation if not resuming
68
+ if history_enabled and not resume_conversation_id:
69
+ try:
70
+ from tsugite.ui.chat_history import start_conversation
71
+
72
+ agent = parse_agent_file(agent_path)
73
+ model = model_override or agent.config.model or "unknown"
74
+
75
+ self.conversation_id = start_conversation(
76
+ agent_name=agent.config.name or agent_path.stem,
77
+ model=model,
78
+ timestamp=self.session_start,
79
+ )
80
+ except Exception as e:
81
+ # Don't fail if history can't be initialized
82
+ print(f"Warning: Failed to initialize conversation history: {e}")
83
+ self.conversation_id = None
84
+
85
+ def load_from_history(self, conversation_id: str, turns: List[Any]) -> None:
86
+ """Load conversation history from JSONL storage.
87
+
88
+ Args:
89
+ conversation_id: Conversation ID to resume
90
+ turns: List of Turn objects from history
91
+
92
+ Raises:
93
+ RuntimeError: If loading fails
94
+ """
95
+ try:
96
+ from tsugite.history import Turn
97
+
98
+ self.conversation_id = conversation_id
99
+ self.conversation_history = []
100
+
101
+ # Convert Turn objects from history to ChatTurn objects
102
+ for turn in turns:
103
+ if not isinstance(turn, Turn):
104
+ continue
105
+
106
+ chat_turn = ChatTurn(
107
+ timestamp=turn.timestamp,
108
+ user_message=turn.user,
109
+ agent_response=turn.assistant,
110
+ tool_calls=turn.tools or [],
111
+ token_count=turn.tokens,
112
+ cost=turn.cost,
113
+ )
114
+ self.conversation_history.append(chat_turn)
115
+
116
+ # Update session_start to first turn's timestamp if available
117
+ if self.conversation_history:
118
+ self.session_start = self.conversation_history[0].timestamp
119
+
120
+ # Prune if history exceeds max_history
121
+ if len(self.conversation_history) > self.max_history:
122
+ self.conversation_history = self.conversation_history[-self.max_history :]
123
+
124
+ except Exception as e:
125
+ raise RuntimeError(f"Failed to load conversation from history: {e}")
126
+
127
+ def add_turn(
128
+ self,
129
+ user_message: str,
130
+ agent_response: str,
131
+ tool_calls: List[str] = None,
132
+ token_count: Optional[int] = None,
133
+ cost: Optional[float] = None,
134
+ execution_steps: Optional[list] = None,
135
+ messages: Optional[list] = None,
136
+ ) -> None:
137
+ """Add a turn to conversation history."""
138
+ turn = ChatTurn(
139
+ timestamp=datetime.now(),
140
+ user_message=user_message,
141
+ agent_response=agent_response,
142
+ tool_calls=tool_calls or [],
143
+ token_count=token_count,
144
+ cost=cost,
145
+ )
146
+ self.conversation_history.append(turn)
147
+
148
+ # Save to persistent history if enabled
149
+ if self.conversation_id:
150
+ try:
151
+ from tsugite.ui.chat_history import save_chat_turn
152
+
153
+ save_chat_turn(
154
+ conversation_id=self.conversation_id,
155
+ user_message=user_message,
156
+ agent_response=agent_response,
157
+ tool_calls=tool_calls or [],
158
+ token_count=token_count,
159
+ cost=cost,
160
+ timestamp=turn.timestamp,
161
+ execution_steps=execution_steps,
162
+ messages=messages,
163
+ )
164
+ except Exception as e:
165
+ # Don't fail the turn if history save fails
166
+ print(f"Warning: Failed to save turn to history: {e}")
167
+
168
+ # Prune old history if needed
169
+ if len(self.conversation_history) > self.max_history:
170
+ self.conversation_history = self.conversation_history[-self.max_history :]
171
+
172
+ def run_turn(self, user_input: str) -> str:
173
+ """Execute one chat turn with the agent.
174
+
175
+ Args:
176
+ user_input: User's message
177
+
178
+ Returns:
179
+ Agent's response
180
+ """
181
+ # Import here to avoid circular dependency
182
+ from tsugite.agent_runner import run_agent
183
+
184
+ try:
185
+ result = run_agent(
186
+ agent_path=self.agent_path,
187
+ prompt=user_input,
188
+ model_override=self.model_override,
189
+ custom_logger=self.custom_logger,
190
+ context={"chat_history": self.conversation_history},
191
+ return_token_usage=True,
192
+ stream=self.stream,
193
+ force_text_mode=True, # Enable text mode for chat UI
194
+ )
195
+
196
+ # Handle tuple return (response, token_count, cost) or (response, token_count) or string return
197
+ response = None
198
+ token_count = None
199
+ cost = None
200
+
201
+ if isinstance(result, tuple):
202
+ if len(result) == 3:
203
+ response, token_count, cost = result
204
+ elif len(result) == 2:
205
+ response, token_count = result
206
+ else:
207
+ response = result[0]
208
+ else:
209
+ response = result
210
+
211
+ self.add_turn(user_input, response, token_count=token_count, cost=cost)
212
+ return response
213
+
214
+ except Exception as e:
215
+ error_msg = f"Error: {str(e)}"
216
+ self.add_turn(user_input, error_msg)
217
+ return error_msg
218
+
219
+ def clear_history(self) -> None:
220
+ """Clear conversation history."""
221
+ self.conversation_history = []
222
+
223
+ def save_conversation(self, path: Path) -> None:
224
+ """Save conversation to JSON file."""
225
+ agent = parse_agent_file(self.agent_path)
226
+
227
+ data = {
228
+ "agent": agent.config.name or str(self.agent_path),
229
+ "model": self.model_override or agent.config.model,
230
+ "created_at": self.session_start.isoformat(),
231
+ "turns": [turn.model_dump(mode="json") for turn in self.conversation_history],
232
+ "metadata": {
233
+ "total_turns": len(self.conversation_history),
234
+ "agent_path": str(self.agent_path),
235
+ },
236
+ }
237
+
238
+ path.write_text(json.dumps(data, indent=2))
239
+
240
+ def load_conversation(self, path: Path) -> None:
241
+ """Load conversation from JSON file."""
242
+ data = json.loads(path.read_text())
243
+
244
+ self.conversation_history = []
245
+
246
+ for turn_data in data.get("turns", []):
247
+ turn = ChatTurn.model_validate(turn_data)
248
+ self.conversation_history.append(turn)
249
+
250
+ if "created_at" in data:
251
+ self.session_start = datetime.fromisoformat(data["created_at"])
252
+
253
+ def get_stats(self) -> Dict[str, Any]:
254
+ """Get session statistics."""
255
+ total_tokens = sum(turn.token_count for turn in self.conversation_history if turn.token_count)
256
+ total_cost = sum(turn.cost for turn in self.conversation_history if turn.cost)
257
+
258
+ return {
259
+ "total_turns": len(self.conversation_history),
260
+ "total_tokens": total_tokens if total_tokens > 0 else None,
261
+ "total_cost": total_cost if total_cost > 0 else None,
262
+ "session_duration": (datetime.now() - self.session_start).total_seconds(),
263
+ "agent": str(self.agent_path),
264
+ "model": self.model_override or "default",
265
+ }
tsugite/ui/chat.tcss ADDED
@@ -0,0 +1,92 @@
1
+ /* Textual CSS for Chat UI */
2
+
3
+ Screen {
4
+ background: $background;
5
+ }
6
+
7
+ Header {
8
+ dock: top;
9
+ height: 3;
10
+ background: $primary-background;
11
+ }
12
+
13
+ /* Top pane: Thought log showing execution summaries */
14
+ ThoughtLog {
15
+ height: 35%;
16
+ border: solid $accent;
17
+ padding: 1;
18
+ background: $surface;
19
+ }
20
+
21
+ ThoughtLog:focus {
22
+ border: double $accent;
23
+ }
24
+
25
+ ThoughtLog > .scrollbar {
26
+ width: 1;
27
+ }
28
+
29
+ ThoughtLog > .scrollbar:focus {
30
+ width: 1;
31
+ }
32
+
33
+ ThoughtEntry {
34
+ width: 100%;
35
+ height: auto;
36
+ margin-bottom: 0;
37
+ }
38
+
39
+ /* Bottom pane: Clean chat conversation */
40
+ MessageList {
41
+ height: 1fr;
42
+ border: solid $primary;
43
+ padding: 1;
44
+ background: $surface;
45
+ }
46
+
47
+ MessageList:focus {
48
+ border: double $primary;
49
+ }
50
+
51
+ MessageList > .scrollbar {
52
+ width: 1;
53
+ }
54
+
55
+ MessageList > .scrollbar:focus {
56
+ width: 1;
57
+ }
58
+
59
+ Message {
60
+ width: 100%;
61
+ height: auto;
62
+ margin-bottom: 1;
63
+ }
64
+
65
+ .separator {
66
+ color: $text-muted;
67
+ height: 1;
68
+ margin: 1 0;
69
+ }
70
+
71
+ .status {
72
+ color: $warning;
73
+ height: auto;
74
+ margin: 1 0;
75
+ }
76
+
77
+ Input {
78
+ border: solid $success;
79
+ background: $surface;
80
+ height: 3;
81
+ }
82
+
83
+ Input:focus {
84
+ border: double $success;
85
+ }
86
+
87
+ Footer {
88
+ dock: bottom;
89
+ height: 1;
90
+ background: $primary-background;
91
+ color: $text-muted;
92
+ }
@@ -0,0 +1,286 @@
1
+ """Chat history integration helpers."""
2
+
3
+ import socket
4
+ from datetime import datetime, timezone
5
+ from typing import List, Optional, Union
6
+
7
+ from tsugite.config import load_config
8
+ from tsugite.history import (
9
+ ConversationMetadata,
10
+ IndexEntry,
11
+ Turn,
12
+ generate_conversation_id,
13
+ load_conversation,
14
+ query_index,
15
+ save_turn_to_history,
16
+ update_index,
17
+ )
18
+
19
+
20
+ def get_machine_name() -> str:
21
+ """Get machine name for conversation tracking.
22
+
23
+ Checks config for custom machine_name, falls back to hostname.
24
+
25
+ Returns:
26
+ Machine name string
27
+ """
28
+ config = load_config()
29
+
30
+ # Check config for override
31
+ if hasattr(config, "machine_name") and config.machine_name:
32
+ return config.machine_name
33
+
34
+ # Auto-detect from hostname
35
+ try:
36
+ return socket.gethostname()
37
+ except Exception:
38
+ return "unknown"
39
+
40
+
41
+ def start_conversation(
42
+ agent_name: str,
43
+ model: str,
44
+ timestamp: Optional[datetime] = None,
45
+ ) -> str:
46
+ """Start a new conversation and save metadata.
47
+
48
+ Creates conversation ID and saves initial metadata line to JSONL file.
49
+
50
+ Args:
51
+ agent_name: Name of the agent
52
+ model: Model identifier (e.g., "openai:gpt-4o")
53
+ timestamp: Optional timestamp (defaults to now)
54
+
55
+ Returns:
56
+ Conversation ID
57
+
58
+ Raises:
59
+ RuntimeError: If save fails
60
+ """
61
+ if timestamp is None:
62
+ timestamp = datetime.now(timezone.utc)
63
+
64
+ conversation_id = generate_conversation_id(agent_name, timestamp)
65
+ machine = get_machine_name()
66
+
67
+ # Create ConversationMetadata model
68
+ metadata = ConversationMetadata(
69
+ id=conversation_id,
70
+ agent=agent_name,
71
+ model=model,
72
+ machine=machine,
73
+ created_at=timestamp,
74
+ timestamp=timestamp,
75
+ )
76
+
77
+ save_turn_to_history(conversation_id, metadata)
78
+
79
+ # Initialize index entry using IndexEntry model
80
+ index_metadata = IndexEntry(
81
+ agent=agent_name,
82
+ model=model,
83
+ machine=machine,
84
+ created_at=timestamp,
85
+ updated_at=timestamp,
86
+ turn_count=0,
87
+ total_tokens=0,
88
+ total_cost=0.0,
89
+ )
90
+ update_index(conversation_id, index_metadata)
91
+
92
+ return conversation_id
93
+
94
+
95
+ def save_chat_turn(
96
+ conversation_id: str,
97
+ user_message: str,
98
+ agent_response: str,
99
+ tool_calls: List[str],
100
+ token_count: Optional[int] = None,
101
+ cost: Optional[float] = None,
102
+ timestamp: Optional[datetime] = None,
103
+ execution_steps: Optional[list] = None,
104
+ messages: Optional[list] = None,
105
+ ) -> None:
106
+ """Save a chat turn to history.
107
+
108
+ Appends turn to JSONL file and updates index.
109
+
110
+ Args:
111
+ conversation_id: Conversation ID
112
+ user_message: User's message
113
+ agent_response: Agent's response
114
+ tool_calls: List of tool calls made
115
+ token_count: Number of tokens used
116
+ cost: Cost of the turn
117
+ timestamp: Optional timestamp (defaults to now)
118
+ execution_steps: Optional list of execution step objects (StepResult)
119
+ messages: Optional full LiteLLM message history
120
+
121
+ Raises:
122
+ RuntimeError: If save fails
123
+ """
124
+ if timestamp is None:
125
+ timestamp = datetime.now(timezone.utc)
126
+
127
+ # Convert execution_steps to dicts if provided
128
+ steps_dicts = None
129
+ if execution_steps:
130
+ steps_dicts = []
131
+ for step in execution_steps:
132
+ if hasattr(step, "__dict__"):
133
+ # Convert dataclass/object to dict
134
+ step_dict = {
135
+ "step_number": getattr(step, "step_number", None),
136
+ "thought": getattr(step, "thought", ""),
137
+ "code": getattr(step, "code", ""),
138
+ "output": getattr(step, "output", ""),
139
+ "error": getattr(step, "error", None),
140
+ "tools_called": getattr(step, "tools_called", []),
141
+ }
142
+ steps_dicts.append(step_dict)
143
+ elif isinstance(step, dict):
144
+ steps_dicts.append(step)
145
+
146
+ # Create Turn model
147
+ turn = Turn(
148
+ timestamp=timestamp,
149
+ user=user_message,
150
+ assistant=agent_response,
151
+ tools=tool_calls,
152
+ tokens=token_count,
153
+ cost=cost,
154
+ steps=steps_dicts,
155
+ messages=messages,
156
+ )
157
+
158
+ save_turn_to_history(conversation_id, turn)
159
+
160
+ # Update index with cumulative stats
161
+ from tsugite.history import get_conversation_metadata
162
+
163
+ metadata = get_conversation_metadata(conversation_id)
164
+
165
+ if metadata:
166
+ # metadata is always IndexEntry
167
+ updated_metadata = IndexEntry(
168
+ agent=metadata.agent,
169
+ model=metadata.model,
170
+ machine=metadata.machine,
171
+ created_at=metadata.created_at,
172
+ updated_at=timestamp,
173
+ turn_count=metadata.turn_count + 1,
174
+ total_tokens=(metadata.total_tokens or 0) + (token_count or 0),
175
+ total_cost=(metadata.total_cost or 0.0) + (cost or 0.0),
176
+ )
177
+ update_index(conversation_id, updated_metadata)
178
+
179
+
180
+ def format_conversation_for_display(turns: List[Union[ConversationMetadata, Turn]]) -> str:
181
+ """Format conversation turns for display.
182
+
183
+ Args:
184
+ turns: List of ConversationMetadata/Turn models from load_conversation()
185
+
186
+ Returns:
187
+ Formatted string for display
188
+ """
189
+ lines = []
190
+
191
+ for turn in turns:
192
+ if isinstance(turn, ConversationMetadata):
193
+ # Header from ConversationMetadata model
194
+ lines.append("=" * 60)
195
+ lines.append(f"Conversation: {turn.id}")
196
+ lines.append(f"Agent: {turn.agent}")
197
+ lines.append(f"Model: {turn.model}")
198
+ lines.append(f"Machine: {turn.machine}")
199
+ lines.append(f"Created: {turn.created_at}")
200
+ lines.append("=" * 60)
201
+ lines.append("")
202
+
203
+ elif isinstance(turn, Turn):
204
+ # Turn from Turn model
205
+ lines.append(f"[{turn.timestamp}]")
206
+ lines.append(f"User: {turn.user}")
207
+ lines.append("")
208
+ lines.append(f"Assistant: {turn.assistant}")
209
+
210
+ if turn.tools:
211
+ lines.append(f" Tools: {', '.join(turn.tools)}")
212
+
213
+ # Display execution steps if available
214
+ if turn.steps:
215
+ lines.append("")
216
+ lines.append(" Execution Steps:")
217
+ for step in turn.steps:
218
+ step_num = step.get("step_number", "?")
219
+ thought = step.get("thought", "").strip()
220
+ code = step.get("code", "").strip()
221
+ output = step.get("output", "").strip()
222
+ error = step.get("error")
223
+ tools_called = step.get("tools_called", [])
224
+
225
+ lines.append(f" Step {step_num}:")
226
+ if thought:
227
+ lines.append(f" Thought: {thought[:100]}{'...' if len(thought) > 100 else ''}")
228
+ if tools_called:
229
+ lines.append(f" Tools: {', '.join(tools_called)}")
230
+ if code:
231
+ # Show first few lines of code
232
+ code_lines = code.split("\n")
233
+ if len(code_lines) <= 3:
234
+ lines.append(f" Code: {code}")
235
+ else:
236
+ lines.append(f" Code: {code_lines[0]}")
237
+ lines.append(f" ... ({len(code_lines) - 1} more lines)")
238
+ if output:
239
+ output_preview = output[:150].replace("\n", " ")
240
+ lines.append(f" Output: {output_preview}{'...' if len(output) > 150 else ''}")
241
+ if error:
242
+ lines.append(f" Error: {error}")
243
+
244
+ lines.append(f" Tokens: {turn.tokens or 0} | Cost: ${turn.cost or 0.0:.4f}")
245
+ lines.append("")
246
+ lines.append("-" * 60)
247
+ lines.append("")
248
+
249
+ return "\n".join(lines)
250
+
251
+
252
+ def get_latest_conversation() -> Optional[str]:
253
+ """Get the most recent conversation ID.
254
+
255
+ Returns:
256
+ Conversation ID of the most recent conversation, or None if no conversations exist
257
+
258
+ Raises:
259
+ RuntimeError: If query fails
260
+ """
261
+ try:
262
+ results = query_index(limit=1)
263
+ if results:
264
+ return results[0].get("conversation_id")
265
+ return None
266
+ except Exception as e:
267
+ raise RuntimeError(f"Failed to query conversation index: {e}")
268
+
269
+
270
+ def load_conversation_history(conversation_id: str) -> List[Turn]:
271
+ """Load conversation history turns (without metadata).
272
+
273
+ Args:
274
+ conversation_id: Conversation ID to load
275
+
276
+ Returns:
277
+ List of Turn objects (excludes ConversationMetadata)
278
+
279
+ Raises:
280
+ FileNotFoundError: If conversation doesn't exist
281
+ RuntimeError: If load fails
282
+ """
283
+ records = load_conversation(conversation_id)
284
+
285
+ # Filter out metadata, return only Turn objects
286
+ return [record for record in records if isinstance(record, Turn)]