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.
@@ -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
@@ -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