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
@@ -0,0 +1,29 @@
1
+ """Conversation history management."""
2
+
3
+ from .index import (
4
+ get_conversation_metadata,
5
+ query_index,
6
+ rebuild_index,
7
+ update_index,
8
+ )
9
+ from .models import ConversationMetadata, IndexEntry, Turn
10
+ from .storage import (
11
+ generate_conversation_id,
12
+ get_history_dir,
13
+ load_conversation,
14
+ save_turn_to_history,
15
+ )
16
+
17
+ __all__ = [
18
+ "ConversationMetadata",
19
+ "IndexEntry",
20
+ "Turn",
21
+ "generate_conversation_id",
22
+ "get_conversation_metadata",
23
+ "get_history_dir",
24
+ "load_conversation",
25
+ "query_index",
26
+ "rebuild_index",
27
+ "save_turn_to_history",
28
+ "update_index",
29
+ ]
@@ -0,0 +1,210 @@
1
+ """JSON index for fast conversation metadata lookup."""
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Dict, List, Optional
7
+
8
+ from .models import ConversationMetadata, IndexEntry, Turn
9
+ from .storage import get_history_dir, list_conversation_files, load_conversation
10
+
11
+
12
+ def _get_index_path() -> Path:
13
+ """Get path to index.json file.
14
+
15
+ Returns:
16
+ Path to index file
17
+ """
18
+ return get_history_dir() / "index.json"
19
+
20
+
21
+ def load_index() -> Dict[str, IndexEntry]:
22
+ """Load conversation index from JSON file.
23
+
24
+ Returns:
25
+ Dictionary mapping conversation IDs to IndexEntry models
26
+ Empty dict if index doesn't exist
27
+ """
28
+ index_path = _get_index_path()
29
+
30
+ if not index_path.exists():
31
+ return {}
32
+
33
+ try:
34
+ with open(index_path, "r", encoding="utf-8") as f:
35
+ raw_index = json.load(f)
36
+
37
+ return {conv_id: IndexEntry.model_validate(metadata) for conv_id, metadata in raw_index.items()}
38
+
39
+ except (json.JSONDecodeError, IOError):
40
+ # Index corrupted, return empty dict
41
+ # Will be rebuilt on next update
42
+ return {}
43
+
44
+
45
+ def save_index(index: Dict[str, IndexEntry]) -> None:
46
+ """Save conversation index to JSON file.
47
+
48
+ Args:
49
+ index: Index data to save (IndexEntry models)
50
+
51
+ Raises:
52
+ RuntimeError: If save fails
53
+ """
54
+ index_path = _get_index_path()
55
+
56
+ # Ensure directory exists
57
+ index_path.parent.mkdir(parents=True, exist_ok=True)
58
+
59
+ try:
60
+ serializable_index = {
61
+ conv_id: entry.model_dump(mode="json", exclude_none=True) for conv_id, entry in index.items()
62
+ }
63
+
64
+ with open(index_path, "w", encoding="utf-8") as f:
65
+ json.dump(serializable_index, f, indent=2, ensure_ascii=False)
66
+ except IOError as e:
67
+ raise RuntimeError(f"Failed to save index to {index_path}: {e}")
68
+
69
+
70
+ def update_index(conversation_id: str, metadata: IndexEntry) -> None:
71
+ """Update index entry for a conversation.
72
+
73
+ Creates or updates the index entry with provided metadata.
74
+ Preserves created_at timestamp for existing entries.
75
+
76
+ Args:
77
+ conversation_id: Conversation ID
78
+ metadata: IndexEntry model with metadata
79
+
80
+ Raises:
81
+ RuntimeError: If save fails
82
+ """
83
+ index = load_index()
84
+
85
+ metadata_dict = metadata.model_dump(mode="json")
86
+
87
+ # Preserve created_at if entry exists
88
+ if conversation_id in index:
89
+ metadata_dict["created_at"] = index[conversation_id].created_at.isoformat()
90
+
91
+ # Ensure updated_at is set
92
+ if "updated_at" not in metadata_dict:
93
+ metadata_dict["updated_at"] = datetime.now(timezone.utc).isoformat()
94
+
95
+ index[conversation_id] = IndexEntry.model_validate(metadata_dict)
96
+ save_index(index)
97
+
98
+
99
+ def rebuild_index() -> int:
100
+ """Rebuild index from all conversation files.
101
+
102
+ Scans all JSONL files and rebuilds the index from scratch.
103
+ Useful for recovering from index corruption or manual file changes.
104
+
105
+ Returns:
106
+ Number of conversations indexed
107
+
108
+ Raises:
109
+ RuntimeError: If rebuild fails
110
+ """
111
+ conversation_files = list_conversation_files()
112
+ new_index = {}
113
+
114
+ for file_path in conversation_files:
115
+ conversation_id = file_path.stem # Filename without .jsonl extension
116
+
117
+ try:
118
+ # Load conversation and extract metadata
119
+ records = load_conversation(conversation_id)
120
+
121
+ if not records:
122
+ continue
123
+
124
+ first_record = records[0]
125
+ last_record = records[-1]
126
+
127
+ # Build metadata from records
128
+ turns = [r for r in records if isinstance(r, Turn)]
129
+ metadata_dict = {
130
+ "agent": first_record.agent if isinstance(first_record, ConversationMetadata) else "unknown",
131
+ "model": first_record.model if isinstance(first_record, ConversationMetadata) else "unknown",
132
+ "machine": first_record.machine if isinstance(first_record, ConversationMetadata) else "unknown",
133
+ "created_at": (
134
+ first_record.timestamp.isoformat()
135
+ if hasattr(first_record, "timestamp")
136
+ else datetime.now(timezone.utc).isoformat()
137
+ ),
138
+ "updated_at": (
139
+ last_record.timestamp.isoformat()
140
+ if hasattr(last_record, "timestamp")
141
+ else datetime.now(timezone.utc).isoformat()
142
+ ),
143
+ "turn_count": len(turns),
144
+ "total_tokens": sum(r.tokens or 0 for r in turns),
145
+ "total_cost": sum(r.cost or 0.0 for r in turns),
146
+ }
147
+
148
+ new_index[conversation_id] = IndexEntry.model_validate(metadata_dict)
149
+
150
+ except Exception as e:
151
+ # Skip files that can't be read
152
+ print(f"Warning: Failed to index {conversation_id}: {e}")
153
+ continue
154
+
155
+ save_index(new_index)
156
+ return len(new_index)
157
+
158
+
159
+ def query_index(
160
+ machine: Optional[str] = None,
161
+ agent: Optional[str] = None,
162
+ limit: Optional[int] = None,
163
+ ) -> List[Dict[str, str]]:
164
+ """Query conversation index with filters.
165
+
166
+ Args:
167
+ machine: Filter by machine name
168
+ agent: Filter by agent name
169
+ limit: Maximum number of results
170
+
171
+ Returns:
172
+ List of conversation metadata dicts (sorted by updated_at, newest first)
173
+ """
174
+ index = load_index()
175
+
176
+ # Convert to list of dicts with conversation_id included
177
+ results = []
178
+ for conv_id, entry in index.items():
179
+ entry_dict = entry.model_dump(mode="json")
180
+ entry_dict["conversation_id"] = conv_id
181
+ results.append(entry_dict)
182
+
183
+ # Apply filters
184
+ if machine:
185
+ results = [r for r in results if r.get("machine") == machine]
186
+
187
+ if agent:
188
+ results = [r for r in results if r.get("agent") == agent]
189
+
190
+ # Sort by updated_at (newest first)
191
+ results.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
192
+
193
+ # Apply limit
194
+ if limit:
195
+ results = results[:limit]
196
+
197
+ return results
198
+
199
+
200
+ def get_conversation_metadata(conversation_id: str) -> Optional[IndexEntry]:
201
+ """Get metadata for a specific conversation from index.
202
+
203
+ Args:
204
+ conversation_id: Conversation ID
205
+
206
+ Returns:
207
+ IndexEntry model if found, None otherwise
208
+ """
209
+ index = load_index()
210
+ return index.get(conversation_id)
@@ -0,0 +1,106 @@
1
+ """Pydantic models for history and conversation data structures."""
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
7
+
8
+
9
+ class ConversationMetadata(BaseModel):
10
+ """Metadata for a conversation session.
11
+
12
+ This is the first record in each conversation JSONL file, containing
13
+ basic information about the conversation.
14
+ """
15
+
16
+ model_config = ConfigDict(
17
+ extra="allow", # Allow extra fields for backward/forward compatibility
18
+ str_strip_whitespace=True, # Auto-strip whitespace from strings
19
+ )
20
+
21
+ type: str = Field(default="metadata", description="Record type identifier")
22
+ id: str = Field(..., description="Unique conversation identifier")
23
+ agent: str = Field(..., description="Agent name used in this conversation")
24
+ model: str = Field(..., description="Model identifier (provider:model format)")
25
+ machine: str = Field(..., description="Hostname/machine name where conversation occurred")
26
+ created_at: datetime = Field(..., description="Conversation creation timestamp")
27
+ timestamp: Optional[datetime] = Field(default=None, description="Alias for created_at (for backward compatibility)")
28
+
29
+ @field_validator("created_at", "timestamp", mode="before")
30
+ @classmethod
31
+ def parse_datetime(cls, v):
32
+ """Parse ISO format strings to datetime objects."""
33
+ if v is None:
34
+ return None
35
+ if isinstance(v, str):
36
+ return datetime.fromisoformat(v.replace("Z", "+00:00"))
37
+ return v
38
+
39
+ @model_validator(mode="after")
40
+ def set_timestamp_default(self):
41
+ """Set timestamp to created_at if not provided."""
42
+ if self.timestamp is None:
43
+ self.timestamp = self.created_at
44
+ return self
45
+
46
+
47
+ class Turn(BaseModel):
48
+ """A single conversation turn (user message + assistant response).
49
+
50
+ Represents one complete interaction in the conversation, including
51
+ the user's input, assistant's response, and metadata about tool usage
52
+ and cost.
53
+ """
54
+
55
+ model_config = ConfigDict(
56
+ extra="allow", # Allow extra fields for future extensions
57
+ str_strip_whitespace=True,
58
+ )
59
+
60
+ type: str = Field(default="turn", description="Record type identifier")
61
+ timestamp: datetime = Field(..., description="When this turn occurred")
62
+ user: str = Field(..., description="User's input message")
63
+ assistant: str = Field(..., description="Assistant's response")
64
+ tools: list[str] = Field(default_factory=list, description="Tools used in this turn")
65
+ tokens: Optional[int] = Field(default=None, description="Total tokens used in this turn")
66
+ cost: Optional[float] = Field(default=None, description="Estimated cost for this turn")
67
+ steps: Optional[list[dict]] = Field(default=None, description="Detailed execution steps (thought/code/output)")
68
+ messages: Optional[list[dict]] = Field(default=None, description="Full LiteLLM message history for this turn")
69
+
70
+ @field_validator("timestamp", mode="before")
71
+ @classmethod
72
+ def parse_datetime(cls, v):
73
+ """Parse ISO format strings to datetime objects."""
74
+ if isinstance(v, str):
75
+ return datetime.fromisoformat(v.replace("Z", "+00:00"))
76
+ return v
77
+
78
+
79
+ class IndexEntry(BaseModel):
80
+ """Entry in the conversation index for fast lookups.
81
+
82
+ The index maps conversation IDs to summary metadata, allowing quick
83
+ queries without reading entire JSONL files.
84
+ """
85
+
86
+ model_config = ConfigDict(
87
+ extra="allow", # Allow extra fields
88
+ str_strip_whitespace=True,
89
+ )
90
+
91
+ agent: str = Field(..., description="Agent name")
92
+ model: str = Field(..., description="Model identifier")
93
+ machine: str = Field(..., description="Hostname/machine name")
94
+ created_at: datetime = Field(..., description="When conversation was created")
95
+ updated_at: datetime = Field(..., description="Last update timestamp")
96
+ turn_count: int = Field(default=0, description="Total number of turns in conversation")
97
+ total_tokens: Optional[int] = Field(default=None, description="Cumulative token count")
98
+ total_cost: Optional[float] = Field(default=None, description="Cumulative cost")
99
+
100
+ @field_validator("created_at", "updated_at", mode="before")
101
+ @classmethod
102
+ def parse_datetime(cls, v):
103
+ """Parse ISO format strings to datetime objects."""
104
+ if isinstance(v, str):
105
+ return datetime.fromisoformat(v.replace("Z", "+00:00"))
106
+ return v
@@ -0,0 +1,157 @@
1
+ """JSONL-based conversation history storage."""
2
+
3
+ import hashlib
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import List, Optional, Union
8
+
9
+ from tsugite.xdg import get_xdg_data_path
10
+
11
+ from .models import ConversationMetadata, Turn
12
+
13
+
14
+ def get_history_dir() -> Path:
15
+ """Get path to conversation history directory.
16
+
17
+ Returns:
18
+ Path to history directory in XDG data location
19
+ (~/.local/share/tsugite/history/)
20
+ """
21
+ return get_xdg_data_path("history")
22
+
23
+
24
+ def generate_conversation_id(agent_name: str, timestamp: Optional[datetime] = None) -> str:
25
+ """Generate unique conversation ID.
26
+
27
+ Format: YYYYMMDD_HHMMSS_{agent}_{hash}
28
+ Example: 20251024_103000_chat_abc123
29
+
30
+ Args:
31
+ agent_name: Name of the agent
32
+ timestamp: Optional timestamp (defaults to now)
33
+
34
+ Returns:
35
+ Unique conversation ID
36
+ """
37
+ if timestamp is None:
38
+ timestamp = datetime.now(timezone.utc)
39
+
40
+ # Format: YYYYMMDD_HHMMSS
41
+ date_str = timestamp.strftime("%Y%m%d_%H%M%S")
42
+
43
+ # Clean agent name (remove special chars)
44
+ clean_agent = "".join(c if c.isalnum() or c == "-" else "_" for c in agent_name)
45
+ clean_agent = clean_agent[:20] # Limit length
46
+
47
+ # Generate short hash from timestamp + agent name
48
+ hash_input = f"{timestamp.isoformat()}_{agent_name}".encode()
49
+ hash_str = hashlib.sha256(hash_input).hexdigest()[:6]
50
+
51
+ return f"{date_str}_{clean_agent}_{hash_str}"
52
+
53
+
54
+ def _get_conversation_path(conversation_id: str) -> Path:
55
+ """Get path to conversation JSONL file.
56
+
57
+ Args:
58
+ conversation_id: Conversation ID
59
+
60
+ Returns:
61
+ Path to JSONL file
62
+ """
63
+ history_dir = get_history_dir()
64
+ return history_dir / f"{conversation_id}.jsonl"
65
+
66
+
67
+ def save_turn_to_history(conversation_id: str, turn_data: Union[Turn, ConversationMetadata]) -> None:
68
+ """Append a turn to conversation history.
69
+
70
+ Creates the history directory and file if they don't exist.
71
+ Appends turn as JSONL line.
72
+
73
+ Args:
74
+ conversation_id: Conversation ID
75
+ turn_data: Turn or ConversationMetadata model to append
76
+
77
+ Raises:
78
+ RuntimeError: If write fails
79
+ """
80
+ conversation_path = _get_conversation_path(conversation_id)
81
+
82
+ # Ensure directory exists
83
+ conversation_path.parent.mkdir(parents=True, exist_ok=True)
84
+
85
+ try:
86
+ with open(conversation_path, "a", encoding="utf-8") as f:
87
+ f.write(turn_data.model_dump_json(exclude_none=True))
88
+ f.write("\n")
89
+ except IOError as e:
90
+ raise RuntimeError(f"Failed to save turn to {conversation_path}: {e}")
91
+
92
+
93
+ def load_conversation(conversation_id: str) -> List[Union[ConversationMetadata, Turn]]:
94
+ """Load full conversation from JSONL file.
95
+
96
+ Args:
97
+ conversation_id: Conversation ID
98
+
99
+ Returns:
100
+ List of ConversationMetadata/Turn models
101
+
102
+ Raises:
103
+ FileNotFoundError: If conversation doesn't exist
104
+ RuntimeError: If read fails
105
+ """
106
+ conversation_path = _get_conversation_path(conversation_id)
107
+
108
+ if not conversation_path.exists():
109
+ raise FileNotFoundError(f"Conversation not found: {conversation_id}")
110
+
111
+ records = []
112
+ try:
113
+ with open(conversation_path, "r", encoding="utf-8") as f:
114
+ for line_num, line in enumerate(f, 1):
115
+ line = line.strip()
116
+ if not line:
117
+ continue # Skip empty lines
118
+
119
+ try:
120
+ data = json.loads(line)
121
+ record_type = data.get("type")
122
+
123
+ if record_type == "metadata":
124
+ records.append(ConversationMetadata.model_validate(data))
125
+ elif record_type == "turn":
126
+ records.append(Turn.model_validate(data))
127
+ else:
128
+ print(f"Warning: Unknown record type '{record_type}' at line {line_num}")
129
+
130
+ except json.JSONDecodeError as e:
131
+ # Skip malformed lines but log warning
132
+ print(f"Warning: Skipping malformed JSON at line {line_num} in {conversation_id}: {e}")
133
+ continue
134
+
135
+ return records
136
+ except IOError as e:
137
+ raise RuntimeError(f"Failed to load conversation {conversation_id}: {e}")
138
+
139
+
140
+ def list_conversation_files() -> List[Path]:
141
+ """List all conversation JSONL files.
142
+
143
+ Returns:
144
+ List of conversation file paths (sorted by modification time, newest first)
145
+ """
146
+ history_dir = get_history_dir()
147
+
148
+ if not history_dir.exists():
149
+ return []
150
+
151
+ try:
152
+ files = list(history_dir.glob("*.jsonl"))
153
+ # Sort by modification time (newest first)
154
+ files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
155
+ return files
156
+ except OSError:
157
+ return []