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.
Files changed (117) hide show
  1. vtx/__init__.py +63 -0
  2. vtx/async_utils.py +40 -0
  3. vtx/builtin_skills/github/SKILL.md +139 -0
  4. vtx/builtin_skills/init/SKILL.md +74 -0
  5. vtx/builtin_skills/review/SKILL.md +73 -0
  6. vtx/builtin_skills/skill-builder/SKILL.md +133 -0
  7. vtx/cli.py +90 -0
  8. vtx/config.py +741 -0
  9. vtx/context/__init__.py +15 -0
  10. vtx/context/_xml.py +8 -0
  11. vtx/context/agent_mds.py +128 -0
  12. vtx/context/git.py +64 -0
  13. vtx/context/loader.py +41 -0
  14. vtx/context/skills.py +423 -0
  15. vtx/core/__init__.py +47 -0
  16. vtx/core/compaction.py +89 -0
  17. vtx/core/errors.py +17 -0
  18. vtx/core/handoff.py +51 -0
  19. vtx/core/scratchpad.py +54 -0
  20. vtx/core/types.py +197 -0
  21. vtx/defaults/__init__.py +0 -0
  22. vtx/defaults/config.yml +53 -0
  23. vtx/diff_display.py +12 -0
  24. vtx/events.py +224 -0
  25. vtx/gh_cli.py +82 -0
  26. vtx/git_branch.py +90 -0
  27. vtx/headless.py +127 -0
  28. vtx/llm/__init__.py +93 -0
  29. vtx/llm/base.py +217 -0
  30. vtx/llm/context_length.py +150 -0
  31. vtx/llm/dynamic_models.py +735 -0
  32. vtx/llm/model_fetcher.py +279 -0
  33. vtx/llm/models.py +78 -0
  34. vtx/llm/oauth/__init__.py +59 -0
  35. vtx/llm/oauth/copilot.py +358 -0
  36. vtx/llm/oauth/dynamic.py +236 -0
  37. vtx/llm/oauth/openai.py +400 -0
  38. vtx/llm/phase_parser.py +270 -0
  39. vtx/llm/provider.yaml +280 -0
  40. vtx/llm/provider_catalog.py +230 -0
  41. vtx/llm/providers/__init__.py +45 -0
  42. vtx/llm/providers/anthropic_sdk.py +256 -0
  43. vtx/llm/providers/mock.py +249 -0
  44. vtx/llm/providers/openai_sdk.py +246 -0
  45. vtx/llm/providers/sanitize.py +14 -0
  46. vtx/llm/sdk/__init__.py +13 -0
  47. vtx/llm/sdk/anthropic.py +382 -0
  48. vtx/llm/sdk/base.py +82 -0
  49. vtx/llm/sdk/openai.py +344 -0
  50. vtx/llm/tool_parser.py +161 -0
  51. vtx/loop.py +272 -0
  52. vtx/notify.py +109 -0
  53. vtx/permissions.py +114 -0
  54. vtx/prompts/__init__.py +45 -0
  55. vtx/prompts/builder.py +86 -0
  56. vtx/prompts/env.py +58 -0
  57. vtx/prompts/identity.py +166 -0
  58. vtx/prompts/tooling.py +36 -0
  59. vtx/py.typed +0 -0
  60. vtx/runtime.py +580 -0
  61. vtx/session.py +868 -0
  62. vtx/sounds/completion.wav +0 -0
  63. vtx/sounds/error.wav +0 -0
  64. vtx/sounds/permission.wav +0 -0
  65. vtx/themes.py +1104 -0
  66. vtx/tools/__init__.py +68 -0
  67. vtx/tools/_read_image.py +106 -0
  68. vtx/tools/_tool_utils.py +90 -0
  69. vtx/tools/base.py +36 -0
  70. vtx/tools/bash.py +371 -0
  71. vtx/tools/edit.py +261 -0
  72. vtx/tools/find.py +132 -0
  73. vtx/tools/read.py +238 -0
  74. vtx/tools/skill.py +278 -0
  75. vtx/tools/web.py +238 -0
  76. vtx/tools/write.py +88 -0
  77. vtx/tools_manager.py +216 -0
  78. vtx/turn.py +789 -0
  79. vtx/ui/__init__.py +0 -0
  80. vtx/ui/agent_runner.py +417 -0
  81. vtx/ui/app.py +665 -0
  82. vtx/ui/app_protocol.py +29 -0
  83. vtx/ui/autocomplete.py +440 -0
  84. vtx/ui/blocks.py +735 -0
  85. vtx/ui/chat.py +613 -0
  86. vtx/ui/clipboard.py +59 -0
  87. vtx/ui/commands/__init__.py +100 -0
  88. vtx/ui/commands/auth.py +306 -0
  89. vtx/ui/commands/base.py +122 -0
  90. vtx/ui/commands/models.py +144 -0
  91. vtx/ui/commands/sessions.py +388 -0
  92. vtx/ui/commands/settings.py +286 -0
  93. vtx/ui/completion_ui.py +313 -0
  94. vtx/ui/export.py +703 -0
  95. vtx/ui/floating_list.py +370 -0
  96. vtx/ui/formatting.py +287 -0
  97. vtx/ui/input.py +760 -0
  98. vtx/ui/latex.py +349 -0
  99. vtx/ui/launch.py +108 -0
  100. vtx/ui/path_complete.py +228 -0
  101. vtx/ui/prompt_history.py +102 -0
  102. vtx/ui/queue_ui.py +141 -0
  103. vtx/ui/selection_mode.py +18 -0
  104. vtx/ui/session_ui.py +235 -0
  105. vtx/ui/startup.py +124 -0
  106. vtx/ui/styles.py +327 -0
  107. vtx/ui/tool_output.py +34 -0
  108. vtx/ui/tree.py +437 -0
  109. vtx/ui/welcome.py +51 -0
  110. vtx/ui/widgets.py +558 -0
  111. vtx/update_check.py +49 -0
  112. vtx/version.py +22 -0
  113. vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
  114. vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
  115. vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
  116. vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
  117. 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
+ )