vtx-coding-agent 0.1.1__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.
- vtx/__init__.py +63 -0
- vtx/async_utils.py +40 -0
- vtx/builtin_skills/github/SKILL.md +139 -0
- vtx/builtin_skills/init/SKILL.md +74 -0
- vtx/builtin_skills/review/SKILL.md +73 -0
- vtx/builtin_skills/skill-builder/SKILL.md +133 -0
- vtx/cli.py +90 -0
- vtx/config.py +741 -0
- vtx/context/__init__.py +15 -0
- vtx/context/_xml.py +8 -0
- vtx/context/agent_mds.py +128 -0
- vtx/context/git.py +64 -0
- vtx/context/loader.py +41 -0
- vtx/context/skills.py +423 -0
- vtx/core/__init__.py +47 -0
- vtx/core/compaction.py +89 -0
- vtx/core/errors.py +17 -0
- vtx/core/handoff.py +51 -0
- vtx/core/scratchpad.py +54 -0
- vtx/core/types.py +197 -0
- vtx/defaults/__init__.py +0 -0
- vtx/defaults/config.yml +53 -0
- vtx/diff_display.py +12 -0
- vtx/events.py +224 -0
- vtx/gh_cli.py +82 -0
- vtx/git_branch.py +90 -0
- vtx/headless.py +127 -0
- vtx/llm/__init__.py +93 -0
- vtx/llm/base.py +217 -0
- vtx/llm/context_length.py +150 -0
- vtx/llm/dynamic_models.py +735 -0
- vtx/llm/model_fetcher.py +279 -0
- vtx/llm/models.py +78 -0
- vtx/llm/oauth/__init__.py +59 -0
- vtx/llm/oauth/copilot.py +358 -0
- vtx/llm/oauth/dynamic.py +236 -0
- vtx/llm/oauth/openai.py +400 -0
- vtx/llm/phase_parser.py +270 -0
- vtx/llm/provider.yaml +280 -0
- vtx/llm/provider_catalog.py +230 -0
- vtx/llm/providers/__init__.py +45 -0
- vtx/llm/providers/anthropic_sdk.py +256 -0
- vtx/llm/providers/mock.py +249 -0
- vtx/llm/providers/openai_sdk.py +246 -0
- vtx/llm/providers/sanitize.py +14 -0
- vtx/llm/sdk/__init__.py +13 -0
- vtx/llm/sdk/anthropic.py +382 -0
- vtx/llm/sdk/base.py +82 -0
- vtx/llm/sdk/openai.py +344 -0
- vtx/llm/tool_parser.py +161 -0
- vtx/loop.py +272 -0
- vtx/notify.py +109 -0
- vtx/permissions.py +114 -0
- vtx/prompts/__init__.py +45 -0
- vtx/prompts/builder.py +86 -0
- vtx/prompts/env.py +58 -0
- vtx/prompts/identity.py +166 -0
- vtx/prompts/tooling.py +36 -0
- vtx/py.typed +0 -0
- vtx/runtime.py +580 -0
- vtx/session.py +868 -0
- vtx/sounds/completion.wav +0 -0
- vtx/sounds/error.wav +0 -0
- vtx/sounds/permission.wav +0 -0
- vtx/themes.py +1104 -0
- vtx/tools/__init__.py +68 -0
- vtx/tools/_read_image.py +106 -0
- vtx/tools/_tool_utils.py +90 -0
- vtx/tools/base.py +36 -0
- vtx/tools/bash.py +371 -0
- vtx/tools/edit.py +261 -0
- vtx/tools/find.py +132 -0
- vtx/tools/read.py +238 -0
- vtx/tools/skill.py +278 -0
- vtx/tools/web.py +238 -0
- vtx/tools/write.py +88 -0
- vtx/tools_manager.py +216 -0
- vtx/turn.py +789 -0
- vtx/ui/__init__.py +0 -0
- vtx/ui/agent_runner.py +417 -0
- vtx/ui/app.py +665 -0
- vtx/ui/app_protocol.py +29 -0
- vtx/ui/autocomplete.py +440 -0
- vtx/ui/blocks.py +735 -0
- vtx/ui/chat.py +613 -0
- vtx/ui/clipboard.py +59 -0
- vtx/ui/commands/__init__.py +100 -0
- vtx/ui/commands/auth.py +306 -0
- vtx/ui/commands/base.py +122 -0
- vtx/ui/commands/models.py +144 -0
- vtx/ui/commands/sessions.py +388 -0
- vtx/ui/commands/settings.py +286 -0
- vtx/ui/completion_ui.py +313 -0
- vtx/ui/export.py +703 -0
- vtx/ui/floating_list.py +370 -0
- vtx/ui/formatting.py +287 -0
- vtx/ui/input.py +760 -0
- vtx/ui/latex.py +349 -0
- vtx/ui/launch.py +108 -0
- vtx/ui/path_complete.py +228 -0
- vtx/ui/prompt_history.py +102 -0
- vtx/ui/queue_ui.py +141 -0
- vtx/ui/selection_mode.py +18 -0
- vtx/ui/session_ui.py +235 -0
- vtx/ui/startup.py +124 -0
- vtx/ui/styles.py +327 -0
- vtx/ui/tool_output.py +34 -0
- vtx/ui/tree.py +437 -0
- vtx/ui/welcome.py +51 -0
- vtx/ui/widgets.py +558 -0
- vtx/update_check.py +49 -0
- vtx/version.py +22 -0
- vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
- vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
- vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
- vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
- vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
vtx/session.py
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session - persistence layer for agent conversations.
|
|
3
|
+
|
|
4
|
+
Sessions are stored as append-only JSONL files. Each line is a JSON entry
|
|
5
|
+
with a type field. The first line is always the session header.
|
|
6
|
+
|
|
7
|
+
Structure:
|
|
8
|
+
{"type": "header", "id": "...", "version": 1, "timestamp": "...",
|
|
9
|
+
"cwd": "...", "system_prompt": "...", "tools": ["read", "edit", ...]}
|
|
10
|
+
{"type": "message", "id": "...", "parent_id": "...", "timestamp": "...", "message": {...}}
|
|
11
|
+
{"type": "message", "id": "...", "parent_id": "...", "timestamp": "...", "message": {...}}
|
|
12
|
+
...
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import builtins
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import uuid
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Literal
|
|
25
|
+
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
|
|
28
|
+
from vtx import get_config_dir
|
|
29
|
+
|
|
30
|
+
from .core.types import (
|
|
31
|
+
AssistantMessage,
|
|
32
|
+
Message,
|
|
33
|
+
StopReason,
|
|
34
|
+
TextContent,
|
|
35
|
+
ToolCall,
|
|
36
|
+
ToolResultMessage,
|
|
37
|
+
UserMessage,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
CURRENT_VERSION = 1
|
|
41
|
+
_SKILL_TRIGGER_HEADER_RE = re.compile(r"^\[([a-z0-9-]+)\]\s*$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _now_iso() -> str:
|
|
45
|
+
return datetime.now(UTC).isoformat()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SessionHeader(BaseModel):
|
|
49
|
+
type: Literal["header"] = "header"
|
|
50
|
+
version: int = CURRENT_VERSION
|
|
51
|
+
id: str
|
|
52
|
+
timestamp: str
|
|
53
|
+
cwd: str
|
|
54
|
+
system_prompt: str | None = None
|
|
55
|
+
tools: builtins.list[str] | None = None
|
|
56
|
+
initial_thinking_level: str = "high"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class EntryBase(BaseModel):
|
|
60
|
+
id: str
|
|
61
|
+
parent_id: str | None
|
|
62
|
+
timestamp: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MessageEntry(EntryBase):
|
|
66
|
+
type: Literal["message"] = "message"
|
|
67
|
+
message: Message
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ThinkingLevelChangeEntry(EntryBase):
|
|
71
|
+
type: Literal["thinking_level_change"] = "thinking_level_change"
|
|
72
|
+
thinking_level: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ModelChangeEntry(EntryBase):
|
|
76
|
+
type: Literal["model_change"] = "model_change"
|
|
77
|
+
provider: str
|
|
78
|
+
model_id: str
|
|
79
|
+
base_url: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CompactionEntry(EntryBase):
|
|
83
|
+
type: Literal["compaction"] = "compaction"
|
|
84
|
+
summary: str
|
|
85
|
+
first_kept_entry_id: str # Retained for compaction metadata; messages use entry order.
|
|
86
|
+
tokens_before: int
|
|
87
|
+
details: dict[str, Any] | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CustomMessageEntry(EntryBase):
|
|
91
|
+
type: Literal["custom_message"] = "custom_message"
|
|
92
|
+
custom_type: str
|
|
93
|
+
content: str
|
|
94
|
+
display: bool = True
|
|
95
|
+
details: dict[str, Any] | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SessionInfoEntry(EntryBase):
|
|
99
|
+
type: Literal["session_info"] = "session_info"
|
|
100
|
+
name: str | None = None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class LeafEntry(EntryBase):
|
|
104
|
+
type: Literal["leaf"] = "leaf"
|
|
105
|
+
target_id: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
SessionEntry = (
|
|
109
|
+
MessageEntry
|
|
110
|
+
| ThinkingLevelChangeEntry
|
|
111
|
+
| ModelChangeEntry
|
|
112
|
+
| CompactionEntry
|
|
113
|
+
| CustomMessageEntry
|
|
114
|
+
| SessionInfoEntry
|
|
115
|
+
| LeafEntry
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class TreeNode:
|
|
121
|
+
entry: SessionEntry
|
|
122
|
+
children: builtins.list[TreeNode] = field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class SessionInfo(BaseModel):
|
|
126
|
+
id: str
|
|
127
|
+
path: Path
|
|
128
|
+
cwd: str
|
|
129
|
+
name: str | None = None
|
|
130
|
+
created: datetime
|
|
131
|
+
modified: datetime
|
|
132
|
+
message_count: int
|
|
133
|
+
first_message: str
|
|
134
|
+
parent_session_id: str | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class SessionTokenTotals:
|
|
139
|
+
input_tokens: int = 0
|
|
140
|
+
output_tokens: int = 0
|
|
141
|
+
context_tokens: int = 0
|
|
142
|
+
cache_read_tokens: int = 0
|
|
143
|
+
cache_write_tokens: int = 0
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def total_tokens(self) -> int:
|
|
147
|
+
return (
|
|
148
|
+
self.input_tokens
|
|
149
|
+
+ self.output_tokens
|
|
150
|
+
+ self.cache_read_tokens
|
|
151
|
+
+ self.cache_write_tokens
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class SessionMessageCounts:
|
|
157
|
+
user_messages: int = 0
|
|
158
|
+
assistant_messages: int = 0
|
|
159
|
+
tool_calls: int = 0
|
|
160
|
+
tool_results: int = 0
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def total_messages(self) -> int:
|
|
164
|
+
return self.user_messages + self.assistant_messages
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Session:
|
|
168
|
+
"""
|
|
169
|
+
Manages conversation persistence as append-only JSONL.
|
|
170
|
+
|
|
171
|
+
Usage:
|
|
172
|
+
# Create new session with initial model/thinking level
|
|
173
|
+
session = Session.create("/path/to/project", provider="openai", model_id="gpt-4")
|
|
174
|
+
|
|
175
|
+
# Add messages
|
|
176
|
+
session.append_message(user_message)
|
|
177
|
+
session.append_message(assistant_message)
|
|
178
|
+
|
|
179
|
+
# Resume later
|
|
180
|
+
session = Session.load(session_file_path)
|
|
181
|
+
messages = session.messages
|
|
182
|
+
|
|
183
|
+
# List sessions
|
|
184
|
+
sessions = Session.list("/path/to/project")
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def generate_id() -> str:
|
|
189
|
+
return uuid.uuid4().hex[:8]
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def get_sessions_dir(cwd: str) -> Path:
|
|
193
|
+
safe_cwd = cwd.replace("/", "-").replace("\\", "-").strip("-")
|
|
194
|
+
sessions_dir = get_config_dir() / "sessions" / safe_cwd
|
|
195
|
+
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
sessions_dir.chmod(0o700)
|
|
197
|
+
return sessions_dir
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
session_id: str,
|
|
202
|
+
cwd: str,
|
|
203
|
+
session_file: Path | None = None,
|
|
204
|
+
persist: bool = True,
|
|
205
|
+
initial_provider: str | None = None,
|
|
206
|
+
initial_model_id: str | None = None,
|
|
207
|
+
initial_thinking_level: str = "high",
|
|
208
|
+
):
|
|
209
|
+
self._id = session_id
|
|
210
|
+
self._cwd = cwd
|
|
211
|
+
self._session_file = session_file
|
|
212
|
+
self._persist = persist
|
|
213
|
+
|
|
214
|
+
# In-memory state
|
|
215
|
+
self._header: SessionHeader | None = None
|
|
216
|
+
self._entries: builtins.list[SessionEntry] = []
|
|
217
|
+
self._by_id: dict[str, SessionEntry] = {}
|
|
218
|
+
self._leaf_id: str | None = None
|
|
219
|
+
|
|
220
|
+
# Initial settings (used as fallback when no entries exist)
|
|
221
|
+
self._initial_provider = initial_provider
|
|
222
|
+
self._initial_model_id = initial_model_id
|
|
223
|
+
self._initial_thinking_level = initial_thinking_level
|
|
224
|
+
|
|
225
|
+
# Track disk persistence state
|
|
226
|
+
self._flushed = False
|
|
227
|
+
self._persisted_entries_count = 0
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def id(self) -> str:
|
|
231
|
+
return self._id
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def cwd(self) -> str:
|
|
235
|
+
return self._cwd
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def session_file(self) -> Path | None:
|
|
239
|
+
return self._session_file
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def system_prompt(self) -> str | None:
|
|
243
|
+
return self._header.system_prompt if self._header else None
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def tools(self) -> builtins.list[str] | None:
|
|
247
|
+
return self._header.tools if self._header else None
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def created_at(self) -> str | None:
|
|
251
|
+
return self._header.timestamp if self._header else None
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def leaf_id(self) -> str | None:
|
|
255
|
+
return self._leaf_id
|
|
256
|
+
|
|
257
|
+
def _generate_entry_id(self) -> str:
|
|
258
|
+
for _ in range(100):
|
|
259
|
+
entry_id = self.generate_id()
|
|
260
|
+
if entry_id not in self._by_id:
|
|
261
|
+
return entry_id
|
|
262
|
+
return uuid.uuid4().hex
|
|
263
|
+
|
|
264
|
+
def _append_entry(self, entry: SessionEntry) -> None:
|
|
265
|
+
self._entries.append(entry)
|
|
266
|
+
self._by_id[entry.id] = entry
|
|
267
|
+
self._leaf_id = entry.target_id if isinstance(entry, LeafEntry) else entry.id
|
|
268
|
+
self._persist_entry(entry)
|
|
269
|
+
|
|
270
|
+
def _persist_entry(self, entry: SessionEntry) -> None:
|
|
271
|
+
if not self._persist or not self._session_file:
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
has_assistant = any(
|
|
275
|
+
isinstance(e, MessageEntry) and e.message.role == "assistant" for e in self._entries
|
|
276
|
+
) or isinstance(entry, LeafEntry)
|
|
277
|
+
if not has_assistant:
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
# If earlier entries were skipped (e.g., pre-assistant user/custom messages),
|
|
281
|
+
# rewrite to include the full sequence before appending incrementally again.
|
|
282
|
+
if self._persisted_entries_count < len(self._entries) - 1:
|
|
283
|
+
self._write_all()
|
|
284
|
+
self._flushed = True
|
|
285
|
+
self._persisted_entries_count = len(self._entries)
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
if not self._flushed:
|
|
289
|
+
self._write_all()
|
|
290
|
+
self._flushed = True
|
|
291
|
+
self._persisted_entries_count = len(self._entries)
|
|
292
|
+
else:
|
|
293
|
+
with open(self._session_file, "a", encoding="utf-8") as f:
|
|
294
|
+
f.write(entry.model_dump_json() + "\n")
|
|
295
|
+
self._persisted_entries_count += 1
|
|
296
|
+
|
|
297
|
+
def _write_all(self) -> None:
|
|
298
|
+
if not self._session_file:
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
self._session_file.parent.mkdir(parents=True, exist_ok=True)
|
|
302
|
+
|
|
303
|
+
with open(self._session_file, "w", encoding="utf-8") as f:
|
|
304
|
+
if self._header:
|
|
305
|
+
f.write(self._header.model_dump_json() + "\n")
|
|
306
|
+
for entry in self._entries:
|
|
307
|
+
f.write(entry.model_dump_json() + "\n")
|
|
308
|
+
|
|
309
|
+
def ensure_persisted(self) -> None:
|
|
310
|
+
if not self._persist or not self._session_file:
|
|
311
|
+
return
|
|
312
|
+
self._write_all()
|
|
313
|
+
self._flushed = True
|
|
314
|
+
self._persisted_entries_count = len(self._entries)
|
|
315
|
+
|
|
316
|
+
def append_message(self, message: Message) -> str:
|
|
317
|
+
entry = MessageEntry(
|
|
318
|
+
id=self._generate_entry_id(),
|
|
319
|
+
parent_id=self._leaf_id,
|
|
320
|
+
timestamp=_now_iso(),
|
|
321
|
+
message=message,
|
|
322
|
+
)
|
|
323
|
+
self._append_entry(entry)
|
|
324
|
+
return entry.id
|
|
325
|
+
|
|
326
|
+
def append_thinking_level_change(self, thinking_level: str) -> str:
|
|
327
|
+
entry = ThinkingLevelChangeEntry(
|
|
328
|
+
id=self._generate_entry_id(),
|
|
329
|
+
parent_id=self._leaf_id,
|
|
330
|
+
timestamp=_now_iso(),
|
|
331
|
+
thinking_level=thinking_level,
|
|
332
|
+
)
|
|
333
|
+
self._append_entry(entry)
|
|
334
|
+
return entry.id
|
|
335
|
+
|
|
336
|
+
def append_model_change(
|
|
337
|
+
self, provider: str, model_id: str, base_url: str | None = None
|
|
338
|
+
) -> str:
|
|
339
|
+
entry = ModelChangeEntry(
|
|
340
|
+
id=self._generate_entry_id(),
|
|
341
|
+
parent_id=self._leaf_id,
|
|
342
|
+
timestamp=_now_iso(),
|
|
343
|
+
provider=provider,
|
|
344
|
+
model_id=model_id,
|
|
345
|
+
base_url=base_url,
|
|
346
|
+
)
|
|
347
|
+
self._append_entry(entry)
|
|
348
|
+
return entry.id
|
|
349
|
+
|
|
350
|
+
def append_compaction(
|
|
351
|
+
self,
|
|
352
|
+
summary: str,
|
|
353
|
+
first_kept_entry_id: str,
|
|
354
|
+
tokens_before: int,
|
|
355
|
+
details: dict[str, Any] | None = None,
|
|
356
|
+
) -> str:
|
|
357
|
+
entry = CompactionEntry(
|
|
358
|
+
id=self._generate_entry_id(),
|
|
359
|
+
parent_id=self._leaf_id,
|
|
360
|
+
timestamp=_now_iso(),
|
|
361
|
+
summary=summary,
|
|
362
|
+
first_kept_entry_id=first_kept_entry_id,
|
|
363
|
+
tokens_before=tokens_before,
|
|
364
|
+
details=details,
|
|
365
|
+
)
|
|
366
|
+
self._append_entry(entry)
|
|
367
|
+
return entry.id
|
|
368
|
+
|
|
369
|
+
def append_custom_message(
|
|
370
|
+
self,
|
|
371
|
+
custom_type: str,
|
|
372
|
+
content: str,
|
|
373
|
+
display: bool = True,
|
|
374
|
+
details: dict[str, Any] | None = None,
|
|
375
|
+
) -> str:
|
|
376
|
+
entry = CustomMessageEntry(
|
|
377
|
+
id=self._generate_entry_id(),
|
|
378
|
+
parent_id=self._leaf_id,
|
|
379
|
+
timestamp=_now_iso(),
|
|
380
|
+
custom_type=custom_type,
|
|
381
|
+
content=content,
|
|
382
|
+
display=display,
|
|
383
|
+
details=details,
|
|
384
|
+
)
|
|
385
|
+
self._append_entry(entry)
|
|
386
|
+
return entry.id
|
|
387
|
+
|
|
388
|
+
def append_session_info(self, name: str) -> str:
|
|
389
|
+
entry = SessionInfoEntry(
|
|
390
|
+
id=self._generate_entry_id(), parent_id=self._leaf_id, timestamp=_now_iso(), name=name
|
|
391
|
+
)
|
|
392
|
+
self._append_entry(entry)
|
|
393
|
+
return entry.id
|
|
394
|
+
|
|
395
|
+
def move_to(self, entry_id: str | None) -> None:
|
|
396
|
+
if entry_id is not None and entry_id not in self._by_id:
|
|
397
|
+
raise ValueError(f"Entry not found: {entry_id}")
|
|
398
|
+
entry = LeafEntry(
|
|
399
|
+
id=self._generate_entry_id(),
|
|
400
|
+
parent_id=self._leaf_id,
|
|
401
|
+
timestamp=_now_iso(),
|
|
402
|
+
target_id=entry_id,
|
|
403
|
+
)
|
|
404
|
+
self._append_entry(entry)
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def entries(self) -> builtins.list[SessionEntry]:
|
|
408
|
+
return self.active_entries
|
|
409
|
+
|
|
410
|
+
@property
|
|
411
|
+
def all_entries(self) -> builtins.list[SessionEntry]:
|
|
412
|
+
return list(self._entries)
|
|
413
|
+
|
|
414
|
+
def get_branch(self, leaf_id: str | None = None) -> builtins.list[SessionEntry]:
|
|
415
|
+
path: builtins.list[SessionEntry] = []
|
|
416
|
+
current_id = self._leaf_id if leaf_id is None else leaf_id
|
|
417
|
+
while current_id:
|
|
418
|
+
entry = self._by_id.get(current_id)
|
|
419
|
+
if entry is None:
|
|
420
|
+
break
|
|
421
|
+
path.append(entry)
|
|
422
|
+
current_id = entry.parent_id
|
|
423
|
+
path.reverse()
|
|
424
|
+
return path
|
|
425
|
+
|
|
426
|
+
@property
|
|
427
|
+
def active_entries(self) -> builtins.list[SessionEntry]:
|
|
428
|
+
return self.get_branch()
|
|
429
|
+
|
|
430
|
+
def get_tree(self) -> builtins.list[TreeNode]:
|
|
431
|
+
tree_entries = [entry for entry in self._entries if not isinstance(entry, LeafEntry)]
|
|
432
|
+
nodes = {entry.id: TreeNode(entry=entry) for entry in tree_entries}
|
|
433
|
+
roots: builtins.list[TreeNode] = []
|
|
434
|
+
for entry in tree_entries:
|
|
435
|
+
node = nodes[entry.id]
|
|
436
|
+
if entry.parent_id is None or entry.parent_id not in nodes:
|
|
437
|
+
roots.append(node)
|
|
438
|
+
else:
|
|
439
|
+
nodes[entry.parent_id].children.append(node)
|
|
440
|
+
for node in nodes.values():
|
|
441
|
+
node.children.sort(key=lambda child: child.entry.timestamp)
|
|
442
|
+
roots.sort(key=lambda node: node.entry.timestamp)
|
|
443
|
+
return roots
|
|
444
|
+
|
|
445
|
+
def get_entry(self, entry_id: str) -> SessionEntry | None:
|
|
446
|
+
return self._by_id.get(entry_id)
|
|
447
|
+
|
|
448
|
+
@property
|
|
449
|
+
def messages(self) -> builtins.list[Message]:
|
|
450
|
+
"""Messages for LLM context. If compaction exists, returns compacted view."""
|
|
451
|
+
last_compaction: CompactionEntry | None = None
|
|
452
|
+
for entry in reversed(self.active_entries):
|
|
453
|
+
if isinstance(entry, CompactionEntry):
|
|
454
|
+
last_compaction = entry
|
|
455
|
+
break
|
|
456
|
+
|
|
457
|
+
if last_compaction is None:
|
|
458
|
+
return [e.message for e in self.active_entries if isinstance(e, MessageEntry)]
|
|
459
|
+
|
|
460
|
+
# Build compacted message list:
|
|
461
|
+
# 1. Synthetic user message asking "what did we do so far?"
|
|
462
|
+
# 2. Assistant message with the compaction summary
|
|
463
|
+
# 3. All MessageEntry entries after the compaction entry
|
|
464
|
+
result: builtins.list[Message] = [
|
|
465
|
+
UserMessage(content="What did we do so far?"),
|
|
466
|
+
AssistantMessage(
|
|
467
|
+
content=[TextContent(text=last_compaction.summary)], stop_reason=StopReason.STOP
|
|
468
|
+
),
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
# Find the compaction entry's position and include messages after it
|
|
472
|
+
past_compaction = False
|
|
473
|
+
for entry in self.active_entries:
|
|
474
|
+
if isinstance(entry, CompactionEntry) and entry.id == last_compaction.id:
|
|
475
|
+
past_compaction = True
|
|
476
|
+
continue
|
|
477
|
+
if past_compaction and isinstance(entry, MessageEntry):
|
|
478
|
+
result.append(entry.message)
|
|
479
|
+
|
|
480
|
+
return result
|
|
481
|
+
|
|
482
|
+
@property
|
|
483
|
+
def all_messages(self) -> builtins.list[Message]:
|
|
484
|
+
"""All messages regardless of compaction (for UI rendering)."""
|
|
485
|
+
return [e.message for e in self.active_entries if isinstance(e, MessageEntry)]
|
|
486
|
+
|
|
487
|
+
def get_last_assistant_text(self) -> str | None:
|
|
488
|
+
for message in reversed(self.messages):
|
|
489
|
+
if not isinstance(message, AssistantMessage):
|
|
490
|
+
continue
|
|
491
|
+
if message.stop_reason == StopReason.INTERRUPTED and not message.content:
|
|
492
|
+
continue
|
|
493
|
+
|
|
494
|
+
text = "".join(
|
|
495
|
+
part.text for part in message.content if isinstance(part, TextContent)
|
|
496
|
+
).strip()
|
|
497
|
+
return text or None
|
|
498
|
+
|
|
499
|
+
return None
|
|
500
|
+
|
|
501
|
+
def token_totals(self) -> SessionTokenTotals:
|
|
502
|
+
input_tokens = 0
|
|
503
|
+
output_tokens = 0
|
|
504
|
+
cache_read_tokens = 0
|
|
505
|
+
cache_write_tokens = 0
|
|
506
|
+
context_tokens = 0
|
|
507
|
+
|
|
508
|
+
for entry in self.active_entries:
|
|
509
|
+
if isinstance(entry, MessageEntry) and isinstance(entry.message, AssistantMessage):
|
|
510
|
+
usage = entry.message.usage
|
|
511
|
+
if usage is None:
|
|
512
|
+
continue
|
|
513
|
+
input_tokens += usage.input_tokens
|
|
514
|
+
output_tokens += usage.output_tokens
|
|
515
|
+
cache_read_tokens += usage.cache_read_tokens
|
|
516
|
+
cache_write_tokens += usage.cache_write_tokens
|
|
517
|
+
context_tokens = (
|
|
518
|
+
usage.input_tokens
|
|
519
|
+
+ usage.output_tokens
|
|
520
|
+
+ usage.cache_read_tokens
|
|
521
|
+
+ usage.cache_write_tokens
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
return SessionTokenTotals(
|
|
525
|
+
input_tokens=input_tokens,
|
|
526
|
+
output_tokens=output_tokens,
|
|
527
|
+
context_tokens=context_tokens,
|
|
528
|
+
cache_read_tokens=cache_read_tokens,
|
|
529
|
+
cache_write_tokens=cache_write_tokens,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
def file_changes_summary(self) -> dict[str, tuple[int, int]]:
|
|
533
|
+
file_changes: dict[str, tuple[int, int]] = {}
|
|
534
|
+
for entry in self.active_entries:
|
|
535
|
+
if isinstance(entry, MessageEntry) and isinstance(entry.message, ToolResultMessage):
|
|
536
|
+
fc = entry.message.file_changes
|
|
537
|
+
if fc:
|
|
538
|
+
prev_added, prev_removed = file_changes.get(fc.path, (0, 0))
|
|
539
|
+
file_changes[fc.path] = (prev_added + fc.added, prev_removed + fc.removed)
|
|
540
|
+
return file_changes
|
|
541
|
+
|
|
542
|
+
def message_counts(self) -> SessionMessageCounts:
|
|
543
|
+
user_messages = 0
|
|
544
|
+
assistant_messages = 0
|
|
545
|
+
tool_calls = 0
|
|
546
|
+
tool_results = 0
|
|
547
|
+
|
|
548
|
+
for entry in self.active_entries:
|
|
549
|
+
if not isinstance(entry, MessageEntry):
|
|
550
|
+
continue
|
|
551
|
+
message = entry.message
|
|
552
|
+
if isinstance(message, UserMessage):
|
|
553
|
+
user_messages += 1
|
|
554
|
+
elif isinstance(message, AssistantMessage):
|
|
555
|
+
assistant_messages += 1
|
|
556
|
+
tool_calls += sum(1 for part in message.content if isinstance(part, ToolCall))
|
|
557
|
+
elif isinstance(message, ToolResultMessage):
|
|
558
|
+
tool_results += 1
|
|
559
|
+
|
|
560
|
+
return SessionMessageCounts(
|
|
561
|
+
user_messages=user_messages,
|
|
562
|
+
assistant_messages=assistant_messages,
|
|
563
|
+
tool_calls=tool_calls,
|
|
564
|
+
tool_results=tool_results,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
@property
|
|
568
|
+
def name(self) -> str | None:
|
|
569
|
+
for entry in reversed(self.active_entries):
|
|
570
|
+
if isinstance(entry, SessionInfoEntry) and entry.name:
|
|
571
|
+
return entry.name
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
@property
|
|
575
|
+
def thinking_level(self) -> str:
|
|
576
|
+
for entry in reversed(self.active_entries):
|
|
577
|
+
if isinstance(entry, ThinkingLevelChangeEntry):
|
|
578
|
+
return entry.thinking_level
|
|
579
|
+
return self._initial_thinking_level
|
|
580
|
+
|
|
581
|
+
@property
|
|
582
|
+
def model(self) -> tuple[str, str, str | None] | None:
|
|
583
|
+
for entry in reversed(self.active_entries):
|
|
584
|
+
if isinstance(entry, ModelChangeEntry):
|
|
585
|
+
return (entry.provider, entry.model_id, entry.base_url)
|
|
586
|
+
|
|
587
|
+
if self._initial_provider and self._initial_model_id:
|
|
588
|
+
return (self._initial_provider, self._initial_model_id, None)
|
|
589
|
+
return None
|
|
590
|
+
|
|
591
|
+
def set_model(self, provider: str, model_id: str, base_url: str | None = None) -> None:
|
|
592
|
+
current = self.model
|
|
593
|
+
if (
|
|
594
|
+
current
|
|
595
|
+
and current[0] == provider
|
|
596
|
+
and current[1] == model_id
|
|
597
|
+
and current[2] == base_url
|
|
598
|
+
):
|
|
599
|
+
return
|
|
600
|
+
self.append_model_change(provider, model_id, base_url)
|
|
601
|
+
|
|
602
|
+
def set_thinking_level(self, thinking_level: str) -> None:
|
|
603
|
+
if self.thinking_level == thinking_level:
|
|
604
|
+
return
|
|
605
|
+
self.append_thinking_level_change(thinking_level)
|
|
606
|
+
|
|
607
|
+
@classmethod
|
|
608
|
+
def create(
|
|
609
|
+
cls,
|
|
610
|
+
cwd: str,
|
|
611
|
+
persist: bool = True,
|
|
612
|
+
provider: str | None = None,
|
|
613
|
+
model_id: str | None = None,
|
|
614
|
+
thinking_level: str = "high",
|
|
615
|
+
system_prompt: str | None = None,
|
|
616
|
+
tools: builtins.list[str] | None = None,
|
|
617
|
+
) -> Session:
|
|
618
|
+
session_id = str(uuid.uuid4())
|
|
619
|
+
timestamp = _now_iso()
|
|
620
|
+
|
|
621
|
+
session = cls(
|
|
622
|
+
session_id=session_id,
|
|
623
|
+
cwd=cwd,
|
|
624
|
+
persist=persist,
|
|
625
|
+
initial_provider=provider,
|
|
626
|
+
initial_model_id=model_id,
|
|
627
|
+
initial_thinking_level=thinking_level,
|
|
628
|
+
)
|
|
629
|
+
session._header = SessionHeader(
|
|
630
|
+
id=session_id,
|
|
631
|
+
timestamp=timestamp,
|
|
632
|
+
cwd=cwd,
|
|
633
|
+
system_prompt=system_prompt,
|
|
634
|
+
tools=tools,
|
|
635
|
+
initial_thinking_level=thinking_level,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
if persist:
|
|
639
|
+
file_timestamp = datetime.fromisoformat(timestamp).strftime("%Y-%m-%dT%H-%M-%S")
|
|
640
|
+
sessions_dir = cls.get_sessions_dir(cwd)
|
|
641
|
+
session._session_file = sessions_dir / f"{file_timestamp}_{session_id}.jsonl"
|
|
642
|
+
|
|
643
|
+
return session
|
|
644
|
+
|
|
645
|
+
@classmethod
|
|
646
|
+
def load(cls, path: Path | str) -> Session:
|
|
647
|
+
path = Path(path)
|
|
648
|
+
if not path.exists():
|
|
649
|
+
raise FileNotFoundError(f"Session file not found: {path}")
|
|
650
|
+
|
|
651
|
+
header: SessionHeader | None = None
|
|
652
|
+
entries: builtins.list[SessionEntry] = []
|
|
653
|
+
|
|
654
|
+
with open(path, encoding="utf-8") as f:
|
|
655
|
+
for line in f:
|
|
656
|
+
line = line.strip()
|
|
657
|
+
if not line:
|
|
658
|
+
continue
|
|
659
|
+
|
|
660
|
+
try:
|
|
661
|
+
data = json.loads(line)
|
|
662
|
+
except json.JSONDecodeError:
|
|
663
|
+
continue
|
|
664
|
+
|
|
665
|
+
entry_type = data.get("type")
|
|
666
|
+
|
|
667
|
+
if entry_type == "header":
|
|
668
|
+
header = SessionHeader.model_validate(data)
|
|
669
|
+
elif entry_type == "message":
|
|
670
|
+
entries.append(MessageEntry.model_validate(data))
|
|
671
|
+
elif entry_type == "thinking_level_change":
|
|
672
|
+
entries.append(ThinkingLevelChangeEntry.model_validate(data))
|
|
673
|
+
elif entry_type == "model_change":
|
|
674
|
+
entries.append(ModelChangeEntry.model_validate(data))
|
|
675
|
+
elif entry_type == "compaction":
|
|
676
|
+
entries.append(CompactionEntry.model_validate(data))
|
|
677
|
+
elif entry_type == "custom_message":
|
|
678
|
+
entries.append(CustomMessageEntry.model_validate(data))
|
|
679
|
+
elif entry_type == "session_info":
|
|
680
|
+
entries.append(SessionInfoEntry.model_validate(data))
|
|
681
|
+
elif entry_type == "leaf":
|
|
682
|
+
entries.append(LeafEntry.model_validate(data))
|
|
683
|
+
|
|
684
|
+
if not header:
|
|
685
|
+
raise ValueError(f"Invalid session file (no header): {path}")
|
|
686
|
+
|
|
687
|
+
session = cls(
|
|
688
|
+
session_id=header.id,
|
|
689
|
+
cwd=header.cwd,
|
|
690
|
+
session_file=path,
|
|
691
|
+
persist=True,
|
|
692
|
+
initial_thinking_level=header.initial_thinking_level,
|
|
693
|
+
)
|
|
694
|
+
session._header = header
|
|
695
|
+
session._entries = entries
|
|
696
|
+
session._by_id = {e.id: e for e in entries}
|
|
697
|
+
session._leaf_id = None
|
|
698
|
+
for entry in entries:
|
|
699
|
+
session._leaf_id = entry.target_id if isinstance(entry, LeafEntry) else entry.id
|
|
700
|
+
session._flushed = True # Already on disk
|
|
701
|
+
session._persisted_entries_count = len(entries)
|
|
702
|
+
|
|
703
|
+
return session
|
|
704
|
+
|
|
705
|
+
@classmethod
|
|
706
|
+
def continue_recent(
|
|
707
|
+
cls,
|
|
708
|
+
cwd: str,
|
|
709
|
+
provider: str | None = None,
|
|
710
|
+
model_id: str | None = None,
|
|
711
|
+
thinking_level: str = "high",
|
|
712
|
+
system_prompt: str | None = None,
|
|
713
|
+
) -> Session:
|
|
714
|
+
sessions_dir = cls.get_sessions_dir(cwd)
|
|
715
|
+
|
|
716
|
+
jsonl_files = list(sessions_dir.glob("*.jsonl"))
|
|
717
|
+
if not jsonl_files:
|
|
718
|
+
return cls.create(
|
|
719
|
+
cwd,
|
|
720
|
+
provider=provider,
|
|
721
|
+
model_id=model_id,
|
|
722
|
+
thinking_level=thinking_level,
|
|
723
|
+
system_prompt=system_prompt,
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
most_recent = max(jsonl_files, key=lambda p: p.stat().st_mtime)
|
|
727
|
+
return cls.load(most_recent)
|
|
728
|
+
|
|
729
|
+
@classmethod
|
|
730
|
+
def continue_by_id(cls, cwd: str, session_id: str) -> Session:
|
|
731
|
+
normalized_id = session_id.strip().lower()
|
|
732
|
+
if not normalized_id:
|
|
733
|
+
raise ValueError("Session ID cannot be empty")
|
|
734
|
+
|
|
735
|
+
sessions = cls.list(cwd)
|
|
736
|
+
exact_matches = [s for s in sessions if s.id.lower() == normalized_id]
|
|
737
|
+
if len(exact_matches) == 1:
|
|
738
|
+
return cls.load(exact_matches[0].path)
|
|
739
|
+
|
|
740
|
+
prefix_matches = [s for s in sessions if s.id.lower().startswith(normalized_id)]
|
|
741
|
+
if len(prefix_matches) == 1:
|
|
742
|
+
return cls.load(prefix_matches[0].path)
|
|
743
|
+
if len(prefix_matches) > 1:
|
|
744
|
+
raise ValueError(f"Session ID prefix is ambiguous: {session_id}")
|
|
745
|
+
|
|
746
|
+
raise FileNotFoundError(f"Session not found: {session_id}")
|
|
747
|
+
|
|
748
|
+
@classmethod
|
|
749
|
+
def list(cls, cwd: str) -> builtins.list[SessionInfo]:
|
|
750
|
+
sessions_dir = cls.get_sessions_dir(cwd)
|
|
751
|
+
if not sessions_dir.exists():
|
|
752
|
+
return []
|
|
753
|
+
|
|
754
|
+
sessions: builtins.list[SessionInfo] = []
|
|
755
|
+
|
|
756
|
+
for path in sessions_dir.glob("*.jsonl"):
|
|
757
|
+
try:
|
|
758
|
+
info = cls.build_session_info(path)
|
|
759
|
+
if info:
|
|
760
|
+
sessions.append(info)
|
|
761
|
+
except (OSError, json.JSONDecodeError, KeyError, ValueError):
|
|
762
|
+
continue
|
|
763
|
+
|
|
764
|
+
sessions.sort(key=lambda s: s.modified, reverse=True)
|
|
765
|
+
return sessions
|
|
766
|
+
|
|
767
|
+
@staticmethod
|
|
768
|
+
def _extract_preview_from_user_message(content: str) -> str:
|
|
769
|
+
text = content.strip()
|
|
770
|
+
if not text:
|
|
771
|
+
return ""
|
|
772
|
+
|
|
773
|
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
774
|
+
if not lines:
|
|
775
|
+
return text
|
|
776
|
+
|
|
777
|
+
header_match = _SKILL_TRIGGER_HEADER_RE.match(lines[0])
|
|
778
|
+
if not header_match:
|
|
779
|
+
return text
|
|
780
|
+
|
|
781
|
+
skill_name = header_match.group(1)
|
|
782
|
+
query_marker_index = next(
|
|
783
|
+
(i for i, line in enumerate(lines[1:], start=1) if line.lower() == "[query]"), -1
|
|
784
|
+
)
|
|
785
|
+
if query_marker_index == -1:
|
|
786
|
+
return f"/{skill_name}"
|
|
787
|
+
|
|
788
|
+
query = " ".join(lines[query_marker_index + 1 :]).strip()
|
|
789
|
+
if not query:
|
|
790
|
+
return f"/{skill_name}"
|
|
791
|
+
|
|
792
|
+
return f"/{skill_name} {query}"
|
|
793
|
+
|
|
794
|
+
@classmethod
|
|
795
|
+
def build_session_info(cls, path: Path) -> SessionInfo | None:
|
|
796
|
+
header: SessionHeader | None = None
|
|
797
|
+
message_count = 0
|
|
798
|
+
first_message = ""
|
|
799
|
+
parent_session_id: str | None = None
|
|
800
|
+
|
|
801
|
+
with open(path, encoding="utf-8") as f:
|
|
802
|
+
for line in f:
|
|
803
|
+
line = line.strip()
|
|
804
|
+
if not line:
|
|
805
|
+
continue
|
|
806
|
+
|
|
807
|
+
try:
|
|
808
|
+
data = json.loads(line)
|
|
809
|
+
except json.JSONDecodeError:
|
|
810
|
+
continue
|
|
811
|
+
|
|
812
|
+
entry_type = data.get("type")
|
|
813
|
+
if entry_type == "header":
|
|
814
|
+
header = SessionHeader.model_validate(data)
|
|
815
|
+
elif entry_type == "message":
|
|
816
|
+
message_count += 1
|
|
817
|
+
msg = data.get("message", {})
|
|
818
|
+
if msg.get("role") == "user" and not first_message:
|
|
819
|
+
content = msg.get("content", "")
|
|
820
|
+
if isinstance(content, str):
|
|
821
|
+
first_message = cls._extract_preview_from_user_message(content)[:100]
|
|
822
|
+
elif isinstance(content, list) and content:
|
|
823
|
+
first_item = content[0]
|
|
824
|
+
if isinstance(first_item, dict) and first_item.get("type") == "text":
|
|
825
|
+
first_message = cls._extract_preview_from_user_message(
|
|
826
|
+
first_item.get("text", "")
|
|
827
|
+
)[:100]
|
|
828
|
+
elif (
|
|
829
|
+
entry_type == "custom_message"
|
|
830
|
+
and data.get("custom_type") == "handoff_backlink"
|
|
831
|
+
):
|
|
832
|
+
details = data.get("details") or {}
|
|
833
|
+
parent_session_id = details.get("target_session_id")
|
|
834
|
+
|
|
835
|
+
if not header:
|
|
836
|
+
return None
|
|
837
|
+
|
|
838
|
+
stat = path.stat()
|
|
839
|
+
return SessionInfo(
|
|
840
|
+
id=header.id,
|
|
841
|
+
path=path,
|
|
842
|
+
cwd=header.cwd,
|
|
843
|
+
created=datetime.fromisoformat(header.timestamp),
|
|
844
|
+
modified=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
|
|
845
|
+
message_count=message_count,
|
|
846
|
+
first_message=first_message or "(no messages)",
|
|
847
|
+
parent_session_id=parent_session_id,
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
@classmethod
|
|
851
|
+
def in_memory(
|
|
852
|
+
cls,
|
|
853
|
+
cwd: str = ".",
|
|
854
|
+
provider: str | None = None,
|
|
855
|
+
model_id: str | None = None,
|
|
856
|
+
thinking_level: str = "high",
|
|
857
|
+
system_prompt: str | None = None,
|
|
858
|
+
tools: builtins.list[str] | None = None,
|
|
859
|
+
) -> Session:
|
|
860
|
+
return cls.create(
|
|
861
|
+
cwd,
|
|
862
|
+
persist=False,
|
|
863
|
+
provider=provider,
|
|
864
|
+
model_id=model_id,
|
|
865
|
+
thinking_level=thinking_level,
|
|
866
|
+
system_prompt=system_prompt,
|
|
867
|
+
tools=tools,
|
|
868
|
+
)
|