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.
- tsugite/__init__.py +6 -0
- tsugite/agent_composition.py +163 -0
- tsugite/agent_inheritance.py +479 -0
- tsugite/agent_preparation.py +236 -0
- tsugite/agent_runner/__init__.py +45 -0
- tsugite/agent_runner/helpers.py +106 -0
- tsugite/agent_runner/history_integration.py +248 -0
- tsugite/agent_runner/metrics.py +100 -0
- tsugite/agent_runner/runner.py +1879 -0
- tsugite/agent_runner/validation.py +70 -0
- tsugite/agent_utils.py +167 -0
- tsugite/attachments/__init__.py +65 -0
- tsugite/attachments/auto_context.py +199 -0
- tsugite/attachments/base.py +34 -0
- tsugite/attachments/file.py +51 -0
- tsugite/attachments/inline.py +31 -0
- tsugite/attachments/storage.py +178 -0
- tsugite/attachments/url.py +59 -0
- tsugite/attachments/youtube.py +101 -0
- tsugite/benchmark/__init__.py +62 -0
- tsugite/benchmark/config.py +183 -0
- tsugite/benchmark/core.py +292 -0
- tsugite/benchmark/discovery.py +377 -0
- tsugite/benchmark/evaluators.py +671 -0
- tsugite/benchmark/execution.py +657 -0
- tsugite/benchmark/metrics.py +204 -0
- tsugite/benchmark/reports.py +420 -0
- tsugite/benchmark/utils.py +288 -0
- tsugite/builtin_agents/chat-assistant.md +53 -0
- tsugite/builtin_agents/default.md +140 -0
- tsugite/builtin_agents.py +5 -0
- tsugite/cache.py +195 -0
- tsugite/cli/__init__.py +1042 -0
- tsugite/cli/agents.py +148 -0
- tsugite/cli/attachments.py +193 -0
- tsugite/cli/benchmark.py +663 -0
- tsugite/cli/cache.py +113 -0
- tsugite/cli/config.py +272 -0
- tsugite/cli/helpers.py +534 -0
- tsugite/cli/history.py +193 -0
- tsugite/cli/init.py +387 -0
- tsugite/cli/mcp.py +193 -0
- tsugite/cli/tools.py +419 -0
- tsugite/config.py +204 -0
- tsugite/console.py +48 -0
- tsugite/constants.py +21 -0
- tsugite/core/__init__.py +19 -0
- tsugite/core/agent.py +774 -0
- tsugite/core/executor.py +300 -0
- tsugite/core/memory.py +67 -0
- tsugite/core/tools.py +271 -0
- tsugite/docker_cli.py +270 -0
- tsugite/events/__init__.py +55 -0
- tsugite/events/base.py +46 -0
- tsugite/events/bus.py +62 -0
- tsugite/events/events.py +224 -0
- tsugite/exceptions.py +40 -0
- tsugite/history/__init__.py +29 -0
- tsugite/history/index.py +210 -0
- tsugite/history/models.py +106 -0
- tsugite/history/storage.py +157 -0
- tsugite/mcp_client.py +219 -0
- tsugite/mcp_config.py +174 -0
- tsugite/md_agents.py +751 -0
- tsugite/models.py +257 -0
- tsugite/renderer.py +151 -0
- tsugite/shell_tool_config.py +265 -0
- tsugite/templates/assistant.md +14 -0
- tsugite/tools/__init__.py +265 -0
- tsugite/tools/agents.py +312 -0
- tsugite/tools/edit_strategies.py +393 -0
- tsugite/tools/fs.py +329 -0
- tsugite/tools/http.py +239 -0
- tsugite/tools/interactive.py +430 -0
- tsugite/tools/shell.py +129 -0
- tsugite/tools/shell_tools.py +214 -0
- tsugite/tools/tasks.py +339 -0
- tsugite/tsugite.py +7 -0
- tsugite/ui/__init__.py +46 -0
- tsugite/ui/base.py +638 -0
- tsugite/ui/chat.py +265 -0
- tsugite/ui/chat.tcss +92 -0
- tsugite/ui/chat_history.py +286 -0
- tsugite/ui/helpers.py +102 -0
- tsugite/ui/jsonl.py +125 -0
- tsugite/ui/live_template.py +529 -0
- tsugite/ui/plain.py +419 -0
- tsugite/ui/textual_chat.py +642 -0
- tsugite/ui/textual_handler.py +225 -0
- tsugite/ui/widgets/__init__.py +6 -0
- tsugite/ui/widgets/base_scroll_log.py +27 -0
- tsugite/ui/widgets/message_list.py +121 -0
- tsugite/ui/widgets/thought_log.py +80 -0
- tsugite/ui_context.py +90 -0
- tsugite/utils.py +367 -0
- tsugite/xdg.py +104 -0
- tsugite_cli-0.3.3.dist-info/METADATA +325 -0
- tsugite_cli-0.3.3.dist-info/RECORD +101 -0
- tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
- tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
- 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
|
+
]
|
tsugite/history/index.py
ADDED
|
@@ -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 []
|