tamfis-code 0.2.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.
- tamfis_code/__init__.py +58 -0
- tamfis_code/__main__.py +4 -0
- tamfis_code/agents.py +371 -0
- tamfis_code/api_client.py +461 -0
- tamfis_code/cli.py +2351 -0
- tamfis_code/completion.py +137 -0
- tamfis_code/config.py +199 -0
- tamfis_code/doctor.py +173 -0
- tamfis_code/enforcer.py +318 -0
- tamfis_code/indexer.py +567 -0
- tamfis_code/instructions.py +121 -0
- tamfis_code/interactive.py +985 -0
- tamfis_code/local_chat.py +117 -0
- tamfis_code/local_tools.py +93 -0
- tamfis_code/mcp.py +538 -0
- tamfis_code/metrics.py +105 -0
- tamfis_code/providers.py +423 -0
- tamfis_code/references.py +336 -0
- tamfis_code/render.py +575 -0
- tamfis_code/runner.py +635 -0
- tamfis_code/runner_local.py +364 -0
- tamfis_code/safety.py +164 -0
- tamfis_code/screenshot.py +382 -0
- tamfis_code/sessions.py +253 -0
- tamfis_code/state.py +449 -0
- tamfis_code/tasks.py +42 -0
- tamfis_code/workspace.py +329 -0
- tamfis_code-0.2.3.dist-info/METADATA +78 -0
- tamfis_code-0.2.3.dist-info/RECORD +32 -0
- tamfis_code-0.2.3.dist-info/WHEEL +5 -0
- tamfis_code-0.2.3.dist-info/entry_points.txt +4 -0
- tamfis_code-0.2.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Screenshot capability for TAMFIS-CODE
|
|
3
|
+
Supports multiple backends: Codex-style (Playwright/Puppeteer) and Claude-style (PIL/OpenCV)
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import json
|
|
9
|
+
import base64
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional, Dict, Any, List, Union
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
19
|
+
HAS_PIL = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
HAS_PIL = False
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import cv2
|
|
25
|
+
HAS_CV2 = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
HAS_CV2 = False
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
import playwright
|
|
31
|
+
HAS_PLAYWRIGHT = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
HAS_PLAYWRIGHT = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ScreenshotOptions:
|
|
38
|
+
"""Options for taking screenshots"""
|
|
39
|
+
width: int = 1920
|
|
40
|
+
height: int = 1080
|
|
41
|
+
full_page: bool = True
|
|
42
|
+
quality: int = 90
|
|
43
|
+
format: str = "png" # png, jpeg, webp
|
|
44
|
+
selector: Optional[str] = None
|
|
45
|
+
wait_for: Optional[float] = 2.0
|
|
46
|
+
device_pixel_ratio: float = 1.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ScreenshotError(Exception):
|
|
50
|
+
"""Exception for screenshot failures"""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ScreenshotTaker:
|
|
55
|
+
"""Main screenshot taking class with multiple backends"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, output_dir: Optional[Path] = None):
|
|
58
|
+
self.output_dir = output_dir or Path(os.getcwd()) / "screenshots"
|
|
59
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
self.backend = self._detect_backend()
|
|
61
|
+
|
|
62
|
+
def _detect_backend(self) -> str:
|
|
63
|
+
"""Detect available backend"""
|
|
64
|
+
if HAS_PLAYWRIGHT:
|
|
65
|
+
return "playwright"
|
|
66
|
+
elif HAS_PIL:
|
|
67
|
+
return "pil"
|
|
68
|
+
elif HAS_CV2:
|
|
69
|
+
return "opencv"
|
|
70
|
+
else:
|
|
71
|
+
return "subprocess"
|
|
72
|
+
|
|
73
|
+
def take_screenshot(
|
|
74
|
+
self,
|
|
75
|
+
url_or_path: str,
|
|
76
|
+
filename: Optional[str] = None,
|
|
77
|
+
options: Optional[ScreenshotOptions] = None,
|
|
78
|
+
backend: Optional[str] = None
|
|
79
|
+
) -> Path:
|
|
80
|
+
"""
|
|
81
|
+
Take a screenshot of a URL or file
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
url_or_path: URL or file path to screenshot
|
|
85
|
+
filename: Output filename (auto-generated if None)
|
|
86
|
+
options: Screenshot options
|
|
87
|
+
backend: Force specific backend
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Path to the screenshot file
|
|
91
|
+
"""
|
|
92
|
+
backend = backend or self.backend
|
|
93
|
+
options = options or ScreenshotOptions()
|
|
94
|
+
|
|
95
|
+
if filename is None:
|
|
96
|
+
import uuid
|
|
97
|
+
filename = f"screenshot_{uuid.uuid4().hex[:8]}.{options.format}"
|
|
98
|
+
|
|
99
|
+
output_path = self.output_dir / filename
|
|
100
|
+
|
|
101
|
+
if backend == "playwright":
|
|
102
|
+
return self._take_screenshot_playwright(url_or_path, output_path, options)
|
|
103
|
+
elif backend == "pil":
|
|
104
|
+
return self._take_screenshot_pil(url_or_path, output_path, options)
|
|
105
|
+
elif backend == "opencv":
|
|
106
|
+
return self._take_screenshot_opencv(url_or_path, output_path, options)
|
|
107
|
+
else:
|
|
108
|
+
return self._take_screenshot_subprocess(url_or_path, output_path, options)
|
|
109
|
+
|
|
110
|
+
def _take_screenshot_playwright(
|
|
111
|
+
self,
|
|
112
|
+
url_or_path: str,
|
|
113
|
+
output_path: Path,
|
|
114
|
+
options: ScreenshotOptions
|
|
115
|
+
) -> Path:
|
|
116
|
+
"""Take screenshot using Playwright (Codex-style)"""
|
|
117
|
+
from playwright.async_api import async_playwright
|
|
118
|
+
import asyncio
|
|
119
|
+
|
|
120
|
+
async def _screenshot():
|
|
121
|
+
async with async_playwright() as p:
|
|
122
|
+
browser = await p.chromium.launch(headless=True)
|
|
123
|
+
page = await browser.new_page(
|
|
124
|
+
viewport={'width': options.width, 'height': options.height},
|
|
125
|
+
device_scale_factor=options.device_pixel_ratio
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if url_or_path.startswith(('http://', 'https://')):
|
|
129
|
+
await page.goto(url_or_path, wait_until='networkidle')
|
|
130
|
+
else:
|
|
131
|
+
# Serve local file
|
|
132
|
+
await page.goto(f'file://{os.path.abspath(url_or_path)}')
|
|
133
|
+
|
|
134
|
+
if options.wait_for:
|
|
135
|
+
await page.wait_for_timeout(int(options.wait_for * 1000))
|
|
136
|
+
|
|
137
|
+
if options.selector:
|
|
138
|
+
await page.wait_for_selector(options.selector)
|
|
139
|
+
element = await page.query_selector(options.selector)
|
|
140
|
+
await element.screenshot(path=str(output_path))
|
|
141
|
+
else:
|
|
142
|
+
await page.screenshot(
|
|
143
|
+
path=str(output_path),
|
|
144
|
+
full_page=options.full_page,
|
|
145
|
+
quality=options.quality if options.format == "jpeg" else None
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
await browser.close()
|
|
149
|
+
return output_path
|
|
150
|
+
|
|
151
|
+
return asyncio.run(_screenshot())
|
|
152
|
+
|
|
153
|
+
def _take_screenshot_pil(
|
|
154
|
+
self,
|
|
155
|
+
url_or_path: str,
|
|
156
|
+
output_path: Path,
|
|
157
|
+
options: ScreenshotOptions
|
|
158
|
+
) -> Path:
|
|
159
|
+
"""Take screenshot using PIL (Claude-style)"""
|
|
160
|
+
if not HAS_PIL:
|
|
161
|
+
raise ScreenshotError("PIL not installed. Install with: pip install Pillow")
|
|
162
|
+
|
|
163
|
+
# For local files, try to render them
|
|
164
|
+
path = Path(url_or_path)
|
|
165
|
+
if path.exists() and path.is_file():
|
|
166
|
+
# Try to render as image
|
|
167
|
+
try:
|
|
168
|
+
img = Image.open(path)
|
|
169
|
+
# Resize if needed
|
|
170
|
+
if img.size[0] > options.width or img.size[1] > options.height:
|
|
171
|
+
img.thumbnail((options.width, options.height))
|
|
172
|
+
img.save(output_path)
|
|
173
|
+
return output_path
|
|
174
|
+
except Exception as e:
|
|
175
|
+
# If not an image, create a text representation
|
|
176
|
+
return self._create_text_image(str(path), output_path, options)
|
|
177
|
+
|
|
178
|
+
# If URL, try using web browser
|
|
179
|
+
return self._create_text_image(url_or_path, output_path, options)
|
|
180
|
+
|
|
181
|
+
def _take_screenshot_opencv(
|
|
182
|
+
self,
|
|
183
|
+
url_or_path: str,
|
|
184
|
+
output_path: Path,
|
|
185
|
+
options: ScreenshotOptions
|
|
186
|
+
) -> Path:
|
|
187
|
+
"""Take screenshot using OpenCV"""
|
|
188
|
+
if not HAS_CV2:
|
|
189
|
+
raise ScreenshotError("OpenCV not installed. Install with: pip install opencv-python")
|
|
190
|
+
|
|
191
|
+
# For local images, use OpenCV
|
|
192
|
+
path = Path(url_or_path)
|
|
193
|
+
if path.exists() and path.is_file():
|
|
194
|
+
img = cv2.imread(str(path))
|
|
195
|
+
if img is not None:
|
|
196
|
+
# Resize if needed
|
|
197
|
+
height, width = img.shape[:2]
|
|
198
|
+
if width > options.width or height > options.height:
|
|
199
|
+
scale = min(options.width/width, options.height/height)
|
|
200
|
+
new_width = int(width * scale)
|
|
201
|
+
new_height = int(height * scale)
|
|
202
|
+
img = cv2.resize(img, (new_width, new_height))
|
|
203
|
+
cv2.imwrite(str(output_path), img)
|
|
204
|
+
return output_path
|
|
205
|
+
|
|
206
|
+
# Fallback to PIL
|
|
207
|
+
return self._take_screenshot_pil(url_or_path, output_path, options)
|
|
208
|
+
|
|
209
|
+
def _take_screenshot_subprocess(
|
|
210
|
+
self,
|
|
211
|
+
url_or_path: str,
|
|
212
|
+
output_path: Path,
|
|
213
|
+
options: ScreenshotOptions
|
|
214
|
+
) -> Path:
|
|
215
|
+
"""Take screenshot using subprocess tools"""
|
|
216
|
+
# Try different tools
|
|
217
|
+
tools = [
|
|
218
|
+
self._try_import_cmd,
|
|
219
|
+
self._try_gnome_screenshot,
|
|
220
|
+
self._try_scrot,
|
|
221
|
+
self._try_chrome_headless,
|
|
222
|
+
self._try_firefox_headless,
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
for tool in tools:
|
|
226
|
+
result = tool(url_or_path, output_path, options)
|
|
227
|
+
if result:
|
|
228
|
+
return output_path
|
|
229
|
+
|
|
230
|
+
# Fallback: create a text screenshot
|
|
231
|
+
return self._create_text_image(url_or_path, output_path, options)
|
|
232
|
+
|
|
233
|
+
def _try_import_cmd(self, url_or_path: str, output_path: Path, options: ScreenshotOptions) -> bool:
|
|
234
|
+
"""Try using import (ImageMagick)"""
|
|
235
|
+
try:
|
|
236
|
+
cmd = ['import', '-window', 'root', str(output_path)]
|
|
237
|
+
subprocess.run(cmd, capture_output=True, check=True)
|
|
238
|
+
return output_path.exists()
|
|
239
|
+
except:
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
def _try_gnome_screenshot(self, url_or_path: str, output_path: Path, options: ScreenshotOptions) -> bool:
|
|
243
|
+
"""Try using gnome-screenshot"""
|
|
244
|
+
try:
|
|
245
|
+
cmd = ['gnome-screenshot', '-f', str(output_path)]
|
|
246
|
+
subprocess.run(cmd, capture_output=True, check=True)
|
|
247
|
+
return output_path.exists()
|
|
248
|
+
except:
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
def _try_scrot(self, url_or_path: str, output_path: Path, options: ScreenshotOptions) -> bool:
|
|
252
|
+
"""Try using scrot"""
|
|
253
|
+
try:
|
|
254
|
+
cmd = ['scrot', str(output_path)]
|
|
255
|
+
subprocess.run(cmd, capture_output=True, check=True)
|
|
256
|
+
return output_path.exists()
|
|
257
|
+
except:
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
def _try_chrome_headless(self, url_or_path: str, output_path: Path, options: ScreenshotOptions) -> bool:
|
|
261
|
+
"""Try using Chrome headless"""
|
|
262
|
+
if not url_or_path.startswith(('http://', 'https://')):
|
|
263
|
+
return False
|
|
264
|
+
try:
|
|
265
|
+
cmd = [
|
|
266
|
+
'google-chrome', '--headless', '--disable-gpu',
|
|
267
|
+
f'--window-size={options.width},{options.height}',
|
|
268
|
+
'--screenshot=' + str(output_path),
|
|
269
|
+
url_or_path
|
|
270
|
+
]
|
|
271
|
+
subprocess.run(cmd, capture_output=True, check=True)
|
|
272
|
+
return output_path.exists()
|
|
273
|
+
except:
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
def _try_firefox_headless(self, url_or_path: str, output_path: Path, options: ScreenshotOptions) -> bool:
|
|
277
|
+
"""Try using Firefox headless"""
|
|
278
|
+
if not url_or_path.startswith(('http://', 'https://')):
|
|
279
|
+
return False
|
|
280
|
+
try:
|
|
281
|
+
cmd = [
|
|
282
|
+
'firefox', '--headless', '--screenshot', str(output_path),
|
|
283
|
+
'--window-size', f'{options.width},{options.height}',
|
|
284
|
+
url_or_path
|
|
285
|
+
]
|
|
286
|
+
subprocess.run(cmd, capture_output=True, check=True)
|
|
287
|
+
return output_path.exists()
|
|
288
|
+
except:
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
def _create_text_image(
|
|
292
|
+
self,
|
|
293
|
+
content: str,
|
|
294
|
+
output_path: Path,
|
|
295
|
+
options: ScreenshotOptions
|
|
296
|
+
) -> Path:
|
|
297
|
+
"""Create a simple text image when screenshot is not possible"""
|
|
298
|
+
if not HAS_PIL:
|
|
299
|
+
# Fallback: create a JSON/HTML file instead
|
|
300
|
+
html_path = output_path.with_suffix('.html')
|
|
301
|
+
html_content = f"""
|
|
302
|
+
<!DOCTYPE html>
|
|
303
|
+
<html>
|
|
304
|
+
<head><title>Screenshot Preview</title></head>
|
|
305
|
+
<body>
|
|
306
|
+
<h1>Content Preview</h1>
|
|
307
|
+
<pre>{content}</pre>
|
|
308
|
+
<p><em>Screenshot not available. Install Playwright: pip install playwright && playwright install</em></p>
|
|
309
|
+
</body>
|
|
310
|
+
</html>
|
|
311
|
+
"""
|
|
312
|
+
html_path.write_text(html_content)
|
|
313
|
+
print(f"⚠️ Screenshot not available. Created HTML preview at {html_path}")
|
|
314
|
+
return html_path
|
|
315
|
+
|
|
316
|
+
img = Image.new('RGB', (options.width, options.height), color='white')
|
|
317
|
+
draw = ImageDraw.Draw(img)
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 14)
|
|
321
|
+
except:
|
|
322
|
+
font = ImageFont.load_default()
|
|
323
|
+
|
|
324
|
+
# Draw content
|
|
325
|
+
lines = str(content).split('\n')
|
|
326
|
+
y = 20
|
|
327
|
+
for line in lines[:100]:
|
|
328
|
+
draw.text((20, y), line[:120], fill='black', font=font)
|
|
329
|
+
y += 18
|
|
330
|
+
|
|
331
|
+
img.save(output_path)
|
|
332
|
+
return output_path
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# CLI Command for screenshot
|
|
336
|
+
async def screenshot_cli(url_or_path: str, **kwargs):
|
|
337
|
+
"""CLI wrapper for screenshot functionality"""
|
|
338
|
+
taker = ScreenshotTaker()
|
|
339
|
+
options = ScreenshotOptions(**kwargs)
|
|
340
|
+
result = taker.take_screenshot(url_or_path, options=options)
|
|
341
|
+
print(f"📸 Screenshot saved: {result}")
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def screenshot_cli_sync(url_or_path: str, **kwargs):
|
|
346
|
+
"""Synchronous wrapper for CLI"""
|
|
347
|
+
import asyncio
|
|
348
|
+
return asyncio.run(screenshot_cli(url_or_path, **kwargs))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# Add to CLI
|
|
352
|
+
def add_screenshot_command(cli):
|
|
353
|
+
"""Add screenshot command to CLI"""
|
|
354
|
+
@cli.command('screenshot')
|
|
355
|
+
@click.argument('url_or_path')
|
|
356
|
+
@click.option('--width', '-w', default=1920, help='Screenshot width')
|
|
357
|
+
@click.option('--height', '-h', default=1080, help='Screenshot height')
|
|
358
|
+
@click.option('--quality', '-q', default=90, help='JPEG quality (1-100)')
|
|
359
|
+
@click.option('--format', '-f', default='png', help='Output format (png/jpeg/webp)')
|
|
360
|
+
@click.option('--full-page', '-F', is_flag=True, help='Capture full page')
|
|
361
|
+
@click.option('--output', '-o', help='Output filename')
|
|
362
|
+
def screenshot_cmd(url_or_path, width, height, quality, format, full_page, output):
|
|
363
|
+
"""Take a screenshot of a URL or file"""
|
|
364
|
+
taker = ScreenshotTaker()
|
|
365
|
+
options = ScreenshotOptions(
|
|
366
|
+
width=width,
|
|
367
|
+
height=height,
|
|
368
|
+
quality=quality,
|
|
369
|
+
format=format,
|
|
370
|
+
full_page=full_page,
|
|
371
|
+
)
|
|
372
|
+
try:
|
|
373
|
+
result = taker.take_screenshot(
|
|
374
|
+
url_or_path,
|
|
375
|
+
filename=output,
|
|
376
|
+
options=options
|
|
377
|
+
)
|
|
378
|
+
click.echo(f"✅ Screenshot saved: {result}")
|
|
379
|
+
except Exception as e:
|
|
380
|
+
click.echo(f"❌ Screenshot failed: {e}")
|
|
381
|
+
|
|
382
|
+
return cli
|
tamfis_code/sessions.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Persistent session management for TAMFIS-CODE"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Optional, List, Dict, Any
|
|
9
|
+
from dataclasses import dataclass, field, asdict
|
|
10
|
+
|
|
11
|
+
SESSION_DB_PATH = Path.home() / ".tamfis" / "sessions.db"
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Message:
|
|
15
|
+
"""Individual message in a session"""
|
|
16
|
+
role: str # 'user', 'assistant', 'system'
|
|
17
|
+
content: str
|
|
18
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
19
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Session:
|
|
23
|
+
"""A complete conversation session"""
|
|
24
|
+
id: str
|
|
25
|
+
name: str
|
|
26
|
+
created_at: datetime
|
|
27
|
+
updated_at: datetime
|
|
28
|
+
messages: List[Message] = field(default_factory=list)
|
|
29
|
+
context: Dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
files: List[str] = field(default_factory=list)
|
|
31
|
+
is_active: bool = True
|
|
32
|
+
|
|
33
|
+
def add_message(self, role: str, content: str, **metadata):
|
|
34
|
+
"""Add a message to the session"""
|
|
35
|
+
self.messages.append(Message(role, content, timestamp=datetime.now(), metadata=metadata))
|
|
36
|
+
self.updated_at = datetime.now()
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
39
|
+
"""Convert to dictionary for serialization"""
|
|
40
|
+
return {
|
|
41
|
+
'id': self.id,
|
|
42
|
+
'name': self.name,
|
|
43
|
+
'created_at': self.created_at.isoformat(),
|
|
44
|
+
'updated_at': self.updated_at.isoformat(),
|
|
45
|
+
'messages': [
|
|
46
|
+
{
|
|
47
|
+
'role': m.role,
|
|
48
|
+
'content': m.content,
|
|
49
|
+
'timestamp': m.timestamp.isoformat(),
|
|
50
|
+
'metadata': m.metadata
|
|
51
|
+
}
|
|
52
|
+
for m in self.messages
|
|
53
|
+
],
|
|
54
|
+
'context': self.context,
|
|
55
|
+
'files': self.files,
|
|
56
|
+
'is_active': self.is_active,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_dict(cls, data: Dict[str, Any]) -> 'Session':
|
|
61
|
+
"""Create Session from dictionary"""
|
|
62
|
+
messages = [
|
|
63
|
+
Message(
|
|
64
|
+
role=m['role'],
|
|
65
|
+
content=m['content'],
|
|
66
|
+
timestamp=datetime.fromisoformat(m['timestamp']),
|
|
67
|
+
metadata=m.get('metadata', {})
|
|
68
|
+
)
|
|
69
|
+
for m in data.get('messages', [])
|
|
70
|
+
]
|
|
71
|
+
return cls(
|
|
72
|
+
id=data['id'],
|
|
73
|
+
name=data['name'],
|
|
74
|
+
created_at=datetime.fromisoformat(data['created_at']),
|
|
75
|
+
updated_at=datetime.fromisoformat(data['updated_at']),
|
|
76
|
+
messages=messages,
|
|
77
|
+
context=data.get('context', {}),
|
|
78
|
+
files=data.get('files', []),
|
|
79
|
+
is_active=data.get('is_active', True),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
class SessionManager:
|
|
83
|
+
"""Manages persistent sessions with SQLite backend"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, db_path: Path = SESSION_DB_PATH):
|
|
86
|
+
self.db_path = db_path
|
|
87
|
+
self._init_db()
|
|
88
|
+
|
|
89
|
+
def _init_db(self):
|
|
90
|
+
"""Initialize the database"""
|
|
91
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
with sqlite3.connect(str(self.db_path)) as conn:
|
|
94
|
+
conn.execute('''
|
|
95
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
96
|
+
id TEXT PRIMARY KEY,
|
|
97
|
+
name TEXT,
|
|
98
|
+
created_at TEXT,
|
|
99
|
+
updated_at TEXT,
|
|
100
|
+
messages TEXT,
|
|
101
|
+
context TEXT,
|
|
102
|
+
files TEXT,
|
|
103
|
+
is_active INTEGER
|
|
104
|
+
)
|
|
105
|
+
''')
|
|
106
|
+
conn.execute('''
|
|
107
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_updated
|
|
108
|
+
ON sessions(updated_at DESC)
|
|
109
|
+
''')
|
|
110
|
+
conn.execute('''
|
|
111
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_active
|
|
112
|
+
ON sessions(is_active)
|
|
113
|
+
''')
|
|
114
|
+
|
|
115
|
+
def save(self, session: Session) -> None:
|
|
116
|
+
"""Save a session to the database"""
|
|
117
|
+
with sqlite3.connect(str(self.db_path)) as conn:
|
|
118
|
+
conn.execute('''
|
|
119
|
+
INSERT OR REPLACE INTO sessions
|
|
120
|
+
(id, name, created_at, updated_at, messages, context, files, is_active)
|
|
121
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
122
|
+
''', (
|
|
123
|
+
session.id,
|
|
124
|
+
session.name,
|
|
125
|
+
session.created_at.isoformat(),
|
|
126
|
+
session.updated_at.isoformat(),
|
|
127
|
+
json.dumps([m.__dict__ if hasattr(m, '__dict__') else asdict(m)
|
|
128
|
+
for m in session.messages], default=str),
|
|
129
|
+
json.dumps(session.context),
|
|
130
|
+
json.dumps(session.files),
|
|
131
|
+
1 if session.is_active else 0
|
|
132
|
+
))
|
|
133
|
+
|
|
134
|
+
def load(self, session_id: str) -> Optional[Session]:
|
|
135
|
+
"""Load a session by ID"""
|
|
136
|
+
with sqlite3.connect(str(self.db_path)) as conn:
|
|
137
|
+
row = conn.execute(
|
|
138
|
+
'SELECT * FROM sessions WHERE id = ?', (session_id,)
|
|
139
|
+
).fetchone()
|
|
140
|
+
|
|
141
|
+
if not row:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
return self._row_to_session(row)
|
|
145
|
+
|
|
146
|
+
def load_active(self) -> Optional[Session]:
|
|
147
|
+
"""Load the most recent active session"""
|
|
148
|
+
with sqlite3.connect(str(self.db_path)) as conn:
|
|
149
|
+
row = conn.execute(
|
|
150
|
+
'SELECT * FROM sessions WHERE is_active = 1 ORDER BY updated_at DESC LIMIT 1'
|
|
151
|
+
).fetchone()
|
|
152
|
+
|
|
153
|
+
if not row:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
return self._row_to_session(row)
|
|
157
|
+
|
|
158
|
+
def list_sessions(self, limit: int = 20) -> List[Session]:
|
|
159
|
+
"""List recent sessions"""
|
|
160
|
+
with sqlite3.connect(str(self.db_path)) as conn:
|
|
161
|
+
rows = conn.execute(
|
|
162
|
+
'SELECT * FROM sessions ORDER BY updated_at DESC LIMIT ?',
|
|
163
|
+
(limit,)
|
|
164
|
+
).fetchall()
|
|
165
|
+
|
|
166
|
+
return [self._row_to_session(row) for row in rows]
|
|
167
|
+
|
|
168
|
+
def delete(self, session_id: str) -> None:
|
|
169
|
+
"""Delete a session"""
|
|
170
|
+
with sqlite3.connect(str(self.db_path)) as conn:
|
|
171
|
+
conn.execute('DELETE FROM sessions WHERE id = ?', (session_id,))
|
|
172
|
+
|
|
173
|
+
def delete_old(self, days: int = 30) -> int:
|
|
174
|
+
"""Delete sessions older than specified days"""
|
|
175
|
+
from datetime import timedelta
|
|
176
|
+
cutoff = (datetime.now() - timedelta(days=days)).isoformat()
|
|
177
|
+
with sqlite3.connect(str(self.db_path)) as conn:
|
|
178
|
+
cursor = conn.execute(
|
|
179
|
+
'DELETE FROM sessions WHERE updated_at < ?',
|
|
180
|
+
(cutoff,)
|
|
181
|
+
)
|
|
182
|
+
return cursor.rowcount
|
|
183
|
+
|
|
184
|
+
def _row_to_session(self, row) -> Session:
|
|
185
|
+
"""Convert database row to Session object"""
|
|
186
|
+
(id, name, created_at, updated_at, messages_json,
|
|
187
|
+
context_json, files_json, is_active) = row
|
|
188
|
+
|
|
189
|
+
messages_data = json.loads(messages_json)
|
|
190
|
+
messages = [
|
|
191
|
+
Message(
|
|
192
|
+
role=m['role'],
|
|
193
|
+
content=m['content'],
|
|
194
|
+
timestamp=datetime.fromisoformat(m['timestamp']),
|
|
195
|
+
metadata=m.get('metadata', {})
|
|
196
|
+
)
|
|
197
|
+
for m in messages_data
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
return Session(
|
|
201
|
+
id=id,
|
|
202
|
+
name=name,
|
|
203
|
+
created_at=datetime.fromisoformat(created_at),
|
|
204
|
+
updated_at=datetime.fromisoformat(updated_at),
|
|
205
|
+
messages=messages,
|
|
206
|
+
context=json.loads(context_json),
|
|
207
|
+
files=json.loads(files_json),
|
|
208
|
+
is_active=bool(is_active),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def create_session(self, name: str = None) -> Session:
|
|
212
|
+
"""Create a new session"""
|
|
213
|
+
session_id = str(uuid.uuid4())[:8]
|
|
214
|
+
now = datetime.now()
|
|
215
|
+
|
|
216
|
+
if not name:
|
|
217
|
+
name = f"session-{session_id}"
|
|
218
|
+
|
|
219
|
+
session = Session(
|
|
220
|
+
id=session_id,
|
|
221
|
+
name=name,
|
|
222
|
+
created_at=now,
|
|
223
|
+
updated_at=now,
|
|
224
|
+
)
|
|
225
|
+
self.save(session)
|
|
226
|
+
return session
|
|
227
|
+
|
|
228
|
+
def archive_session(self, session_id: str) -> None:
|
|
229
|
+
"""Archive a session (set inactive)"""
|
|
230
|
+
with sqlite3.connect(str(self.db_path)) as conn:
|
|
231
|
+
conn.execute(
|
|
232
|
+
'UPDATE sessions SET is_active = 0 WHERE id = ?',
|
|
233
|
+
(session_id,)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def fork_session(self, session_id: str, new_name: str = None) -> Optional[Session]:
|
|
237
|
+
"""Fork a session (copy with new ID)"""
|
|
238
|
+
original = self.load(session_id)
|
|
239
|
+
if not original:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
new_session = Session(
|
|
243
|
+
id=str(uuid.uuid4())[:8],
|
|
244
|
+
name=new_name or f"{original.name}-fork",
|
|
245
|
+
created_at=datetime.now(),
|
|
246
|
+
updated_at=datetime.now(),
|
|
247
|
+
messages=[m for m in original.messages],
|
|
248
|
+
context=dict(original.context),
|
|
249
|
+
files=list(original.files),
|
|
250
|
+
is_active=True,
|
|
251
|
+
)
|
|
252
|
+
self.save(new_session)
|
|
253
|
+
return new_session
|