epi-recorder 2.1.3__py3-none-any.whl → 2.3.0__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 (45) hide show
  1. epi_analyzer/__init__.py +9 -0
  2. epi_analyzer/detector.py +337 -0
  3. epi_cli/__init__.py +4 -0
  4. epi_cli/__main__.py +4 -0
  5. epi_cli/chat.py +21 -3
  6. epi_cli/debug.py +107 -0
  7. epi_cli/keys.py +4 -0
  8. epi_cli/ls.py +5 -1
  9. epi_cli/main.py +8 -0
  10. epi_cli/record.py +4 -0
  11. epi_cli/run.py +12 -4
  12. epi_cli/verify.py +4 -0
  13. epi_cli/view.py +4 -0
  14. epi_core/__init__.py +5 -1
  15. epi_core/container.py +68 -55
  16. epi_core/redactor.py +4 -0
  17. epi_core/schemas.py +6 -2
  18. epi_core/serialize.py +4 -0
  19. epi_core/storage.py +186 -0
  20. epi_core/trust.py +4 -0
  21. epi_recorder/__init__.py +13 -1
  22. epi_recorder/api.py +211 -5
  23. epi_recorder/async_api.py +151 -0
  24. epi_recorder/bootstrap.py +4 -0
  25. epi_recorder/environment.py +4 -0
  26. epi_recorder/patcher.py +79 -19
  27. epi_recorder/test_import.py +2 -0
  28. epi_recorder/test_script.py +2 -0
  29. epi_recorder/wrappers/__init__.py +16 -0
  30. epi_recorder/wrappers/base.py +79 -0
  31. epi_recorder/wrappers/openai.py +178 -0
  32. epi_recorder-2.3.0.dist-info/METADATA +269 -0
  33. epi_recorder-2.3.0.dist-info/RECORD +41 -0
  34. {epi_recorder-2.1.3.dist-info → epi_recorder-2.3.0.dist-info}/WHEEL +1 -1
  35. epi_recorder-2.3.0.dist-info/licenses/LICENSE +21 -0
  36. {epi_recorder-2.1.3.dist-info → epi_recorder-2.3.0.dist-info}/top_level.txt +1 -0
  37. epi_viewer_static/app.js +113 -7
  38. epi_viewer_static/crypto.js +3 -0
  39. epi_viewer_static/index.html +4 -2
  40. epi_viewer_static/viewer_lite.css +3 -1
  41. epi_postinstall.py +0 -197
  42. epi_recorder-2.1.3.dist-info/METADATA +0 -577
  43. epi_recorder-2.1.3.dist-info/RECORD +0 -34
  44. epi_recorder-2.1.3.dist-info/licenses/LICENSE +0 -201
  45. {epi_recorder-2.1.3.dist-info → epi_recorder-2.3.0.dist-info}/entry_points.txt +0 -0
epi_core/container.py CHANGED
@@ -11,6 +11,7 @@ Implements the EPI file format specification:
11
11
  import hashlib
12
12
  import json
13
13
  import tempfile
14
+ import threading
14
15
  import zipfile
15
16
  from pathlib import Path
16
17
  from typing import Optional
@@ -21,6 +22,9 @@ from epi_core.schemas import ManifestModel
21
22
  # EPI mimetype constant (vendor-specific MIME type per RFC 6838)
22
23
  EPI_MIMETYPE = "application/vnd.epi+zip"
23
24
 
25
+ # Thread-safe lock for ZIP packing operations (prevents concurrent corruption)
26
+ _zip_pack_lock = threading.Lock()
27
+
24
28
 
25
29
  class EPIContainer:
26
30
  """
@@ -157,6 +161,8 @@ class EPIContainer:
157
161
  """
158
162
  Create a .epi file from a source directory.
159
163
 
164
+ Thread-safe: Uses a module-level lock to prevent concurrent ZIP corruption.
165
+
160
166
  The packing process:
161
167
  1. Write mimetype first (uncompressed) per ZIP spec
162
168
  2. Hash all files in source_dir
@@ -173,64 +179,67 @@ class EPIContainer:
173
179
  FileNotFoundError: If source_dir doesn't exist
174
180
  ValueError: If source_dir is not a directory
175
181
  """
176
- if not source_dir.exists():
177
- raise FileNotFoundError(f"Source directory not found: {source_dir}")
178
-
179
- if not source_dir.is_dir():
180
- raise ValueError(f"Source must be a directory: {source_dir}")
181
-
182
- # Ensure output directory exists
183
- output_path.parent.mkdir(parents=True, exist_ok=True)
184
-
185
- # Collect all files and compute hashes
186
- file_manifest = {}
187
- files_to_pack = []
188
-
189
- for file_path in source_dir.rglob("*"):
190
- if file_path.is_file():
191
- # Get relative path for archive
192
- rel_path = file_path.relative_to(source_dir)
193
- arc_name = str(rel_path).replace("\\", "/") # Use forward slashes in ZIP
194
-
195
- # Compute hash
196
- file_hash = EPIContainer._compute_file_hash(file_path)
197
- file_manifest[arc_name] = file_hash
198
-
199
- files_to_pack.append((file_path, arc_name))
200
-
201
- # Update manifest with file hashes
202
- manifest.file_manifest = file_manifest
203
-
204
- # Create embedded viewer with data injection
205
- viewer_html = EPIContainer._create_embedded_viewer(source_dir, manifest)
206
-
207
- # Create ZIP file
208
- with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
209
- # 1. Write mimetype FIRST and UNCOMPRESSED (per EPI spec)
210
- zf.writestr(
211
- "mimetype",
212
- EPI_MIMETYPE,
213
- compress_type=zipfile.ZIP_STORED # No compression
214
- )
182
+ # CRITICAL: Acquire lock to prevent concurrent ZIP corruption
183
+ # Multiple threads writing to ZIP simultaneously causes file header mismatches
184
+ with _zip_pack_lock:
185
+ if not source_dir.exists():
186
+ raise FileNotFoundError(f"Source directory not found: {source_dir}")
187
+
188
+ if not source_dir.is_dir():
189
+ raise ValueError(f"Source must be a directory: {source_dir}")
190
+
191
+ # Ensure output directory exists
192
+ output_path.parent.mkdir(parents=True, exist_ok=True)
193
+
194
+ # Collect all files and compute hashes
195
+ file_manifest = {}
196
+ files_to_pack = []
197
+
198
+ for file_path in source_dir.rglob("*"):
199
+ if file_path.is_file():
200
+ # Get relative path for archive
201
+ rel_path = file_path.relative_to(source_dir)
202
+ arc_name = str(rel_path).replace("\\", "/") # Use forward slashes in ZIP
203
+
204
+ # Compute hash
205
+ file_hash = EPIContainer._compute_file_hash(file_path)
206
+ file_manifest[arc_name] = file_hash
207
+
208
+ files_to_pack.append((file_path, arc_name))
215
209
 
216
- # 2. Write all other files
217
- for file_path, arc_name in files_to_pack:
218
- zf.write(file_path, arc_name, compress_type=zipfile.ZIP_DEFLATED)
210
+ # Update manifest with file hashes
211
+ manifest.file_manifest = file_manifest
219
212
 
220
- # 3. Write embedded viewer
221
- zf.writestr(
222
- "viewer.html",
223
- viewer_html,
224
- compress_type=zipfile.ZIP_DEFLATED
225
- )
213
+ # Create embedded viewer with data injection
214
+ viewer_html = EPIContainer._create_embedded_viewer(source_dir, manifest)
226
215
 
227
- # 4. Write manifest.json LAST (after all files are hashed)
228
- manifest_json = manifest.model_dump_json(indent=2)
229
- zf.writestr(
230
- "manifest.json",
231
- manifest_json,
232
- compress_type=zipfile.ZIP_DEFLATED
233
- )
216
+ # Create ZIP file
217
+ with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
218
+ # 1. Write mimetype FIRST and UNCOMPRESSED (per EPI spec)
219
+ zf.writestr(
220
+ "mimetype",
221
+ EPI_MIMETYPE,
222
+ compress_type=zipfile.ZIP_STORED # No compression
223
+ )
224
+
225
+ # 2. Write all other files
226
+ for file_path, arc_name in files_to_pack:
227
+ zf.write(file_path, arc_name, compress_type=zipfile.ZIP_DEFLATED)
228
+
229
+ # 3. Write embedded viewer
230
+ zf.writestr(
231
+ "viewer.html",
232
+ viewer_html,
233
+ compress_type=zipfile.ZIP_DEFLATED
234
+ )
235
+
236
+ # 4. Write manifest.json LAST (after all files are hashed)
237
+ manifest_json = manifest.model_dump_json(indent=2)
238
+ zf.writestr(
239
+ "manifest.json",
240
+ manifest_json,
241
+ compress_type=zipfile.ZIP_DEFLATED
242
+ )
234
243
 
235
244
  @staticmethod
236
245
  def unpack(epi_path: Path, dest_dir: Optional[Path] = None) -> Path:
@@ -350,3 +359,7 @@ class EPIContainer:
350
359
  mismatches[filename] = f"Hash mismatch: expected {expected_hash}, got {actual_hash}"
351
360
 
352
361
  return (len(mismatches) == 0, mismatches)
362
+
363
+
364
+
365
+
epi_core/redactor.py CHANGED
@@ -277,3 +277,7 @@ def get_default_redactor() -> Redactor:
277
277
  pass # Fail silently, use defaults
278
278
 
279
279
  return Redactor(config_path=config_path if config_path.exists() else None)
280
+
281
+
282
+
283
+
epi_core/schemas.py CHANGED
@@ -18,7 +18,7 @@ class ManifestModel(BaseModel):
18
18
  """
19
19
 
20
20
  spec_version: str = Field(
21
- default="1.1-json",
21
+ default="2.3.0",
22
22
  description="EPI specification version"
23
23
  )
24
24
 
@@ -145,4 +145,8 @@ class StepModel(BaseModel):
145
145
  }
146
146
  }
147
147
  }
148
- )
148
+ )
149
+
150
+
151
+
152
+
epi_core/serialize.py CHANGED
@@ -158,3 +158,7 @@ def verify_hash(model: BaseModel, expected_hash: str, exclude_fields: set[str] |
158
158
  """
159
159
  actual_hash = get_canonical_hash(model, exclude_fields)
160
160
  return actual_hash == expected_hash
161
+
162
+
163
+
164
+
epi_core/storage.py ADDED
@@ -0,0 +1,186 @@
1
+ """
2
+ SQLite-based storage for EPI recordings.
3
+
4
+ Provides atomic, crash-safe storage replacing JSONL files.
5
+ SQLite transactions ensure no data corruption on crashes.
6
+ """
7
+
8
+ import sqlite3
9
+ import json
10
+ import time
11
+ from pathlib import Path
12
+ from typing import List, Dict, Any, Optional
13
+ from datetime import datetime
14
+
15
+ from .schemas import StepModel
16
+
17
+
18
+ class EpiStorage:
19
+ """
20
+ SQLite-based atomic storage for agent execution.
21
+ Replaces JSONL (which corrupts on crashes).
22
+ """
23
+
24
+ def __init__(self, session_id: str, output_dir: Path):
25
+ """
26
+ Initialize SQLite storage.
27
+
28
+ Args:
29
+ session_id: Unique session identifier
30
+ output_dir: Directory for database file
31
+ """
32
+ self.session_id = session_id
33
+ self.output_dir = Path(output_dir)
34
+ self.output_dir.mkdir(parents=True, exist_ok=True)
35
+
36
+ self.db_path = self.output_dir / f"{session_id}_temp.db"
37
+ self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
38
+ self._init_tables()
39
+
40
+ def _init_tables(self):
41
+ """Initialize database schema"""
42
+ self.conn.execute('''
43
+ CREATE TABLE IF NOT EXISTS steps (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ step_index INTEGER NOT NULL,
46
+ timestamp TEXT NOT NULL,
47
+ kind TEXT NOT NULL,
48
+ content TEXT NOT NULL,
49
+ created_at REAL NOT NULL
50
+ )
51
+ ''')
52
+
53
+ self.conn.execute('''
54
+ CREATE TABLE IF NOT EXISTS metadata (
55
+ key TEXT PRIMARY KEY,
56
+ value TEXT NOT NULL
57
+ )
58
+ ''')
59
+
60
+ self.conn.execute('''
61
+ CREATE INDEX IF NOT EXISTS idx_steps_index
62
+ ON steps(step_index)
63
+ ''')
64
+
65
+ self.conn.commit()
66
+
67
+ def add_step(self, step: StepModel) -> None:
68
+ """
69
+ Atomic insert of execution step.
70
+ Survives process crashes.
71
+
72
+ Args:
73
+ step: StepModel to persist
74
+ """
75
+ self.conn.execute(
76
+ '''INSERT INTO steps
77
+ (step_index, timestamp, kind, content, created_at)
78
+ VALUES (?, ?, ?, ?, ?)''',
79
+ (
80
+ step.index,
81
+ step.timestamp.isoformat(),
82
+ step.kind,
83
+ step.model_dump_json(),
84
+ time.time()
85
+ )
86
+ )
87
+ self.conn.commit()
88
+
89
+ def get_steps(self) -> List[StepModel]:
90
+ """
91
+ Retrieve all steps in order.
92
+
93
+ Returns:
94
+ List of StepModel instances
95
+ """
96
+ cursor = self.conn.execute(
97
+ 'SELECT content FROM steps ORDER BY step_index'
98
+ )
99
+ rows = cursor.fetchall()
100
+
101
+ steps = []
102
+ for row in rows:
103
+ step_data = json.loads(row[0])
104
+ steps.append(StepModel(**step_data))
105
+
106
+ return steps
107
+
108
+ def set_metadata(self, key: str, value: str) -> None:
109
+ """
110
+ Set metadata key-value pair.
111
+
112
+ Args:
113
+ key: Metadata key
114
+ value: Metadata value
115
+ """
116
+ self.conn.execute(
117
+ 'INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)',
118
+ (key, value)
119
+ )
120
+ self.conn.commit()
121
+
122
+ def get_metadata(self, key: str) -> Optional[str]:
123
+ """
124
+ Get metadata value.
125
+
126
+ Args:
127
+ key: Metadata key
128
+
129
+ Returns:
130
+ Metadata value or None
131
+ """
132
+ cursor = self.conn.execute(
133
+ 'SELECT value FROM metadata WHERE key = ?',
134
+ (key,)
135
+ )
136
+ row = cursor.fetchone()
137
+ return row[0] if row else None
138
+
139
+ def close(self) -> None:
140
+ """Close database connection."""
141
+ if self.conn:
142
+ self.conn.close()
143
+
144
+ def export_to_jsonl(self, output_path: Path) -> None:
145
+ """
146
+ Export steps to JSONL file for backwards compatibility.
147
+
148
+ Args:
149
+ output_path: Path to JSONL file
150
+ """
151
+ steps = self.get_steps()
152
+ with open(output_path, 'w', encoding='utf-8') as f:
153
+ for step in steps:
154
+ f.write(step.model_dump_json() + '\n')
155
+
156
+ def finalize(self) -> Path:
157
+ """
158
+ Finalize recording and rename to final path.
159
+ This ensures we never have half-written files.
160
+
161
+ Returns:
162
+ Path to finalized database file
163
+ """
164
+ # Add finalization metadata
165
+ self.set_metadata('finalized_at', datetime.utcnow().isoformat())
166
+ self.set_metadata('session_id', self.session_id)
167
+
168
+ # Close connection
169
+ self.close()
170
+
171
+ # Atomic rename (SQLite transaction guarantees consistency)
172
+ final_path = self.output_dir / "steps.jsonl"
173
+
174
+ # Export to JSONL for backwards compatibility
175
+ self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
176
+ self.export_to_jsonl(final_path)
177
+ self.close()
178
+
179
+ # Clean up temp DB
180
+ self.db_path.unlink(missing_ok=True)
181
+
182
+ return final_path
183
+
184
+
185
+
186
+
epi_core/trust.py CHANGED
@@ -244,3 +244,7 @@ def create_verification_report(
244
244
  report["trust_message"] = "Integrity compromised - do not trust"
245
245
 
246
246
  return report
247
+
248
+
249
+
250
+
epi_recorder/__init__.py CHANGED
@@ -4,7 +4,7 @@ EPI Recorder - Runtime interception and workflow capture.
4
4
  Python API for recording AI workflows with cryptographic verification.
5
5
  """
6
6
 
7
- __version__ = "2.1.3"
7
+ __version__ = "2.3.0"
8
8
 
9
9
  # Export Python API
10
10
  from epi_recorder.api import (
@@ -13,9 +13,21 @@ from epi_recorder.api import (
13
13
  get_current_session
14
14
  )
15
15
 
16
+ # Export wrapper clients (new in v2.3.0)
17
+ from epi_recorder.wrappers import (
18
+ wrap_openai,
19
+ TracedOpenAI,
20
+ )
21
+
16
22
  __all__ = [
17
23
  "EpiRecorderSession",
18
24
  "record",
19
25
  "get_current_session",
26
+ "wrap_openai",
27
+ "TracedOpenAI",
20
28
  "__version__"
21
29
  ]
30
+
31
+
32
+
33
+
epi_recorder/api.py CHANGED
@@ -54,6 +54,8 @@ class EpiRecorderSession:
54
54
  metrics: Optional[Dict[str, Union[float, str]]] = None,
55
55
  approved_by: Optional[str] = None,
56
56
  metadata_tags: Optional[List[str]] = None, # Renamed to avoid conflict with tags parameter
57
+ # Legacy mode (deprecated)
58
+ legacy_patching: bool = False,
57
59
  ):
58
60
  """
59
61
  Initialize EPI recording session.
@@ -70,6 +72,7 @@ class EpiRecorderSession:
70
72
  metrics: Key-value metrics for this workflow (accuracy, latency, etc.)
71
73
  approved_by: Person or entity who approved this workflow execution
72
74
  metadata_tags: Tags for categorizing this workflow (renamed from tags to avoid conflict)
75
+ legacy_patching: Enable deprecated monkey patching mode (default: False)
73
76
  """
74
77
  self.output_path = Path(output_path)
75
78
  self.workflow_name = workflow_name or "untitled"
@@ -85,6 +88,9 @@ class EpiRecorderSession:
85
88
  self.approved_by = approved_by
86
89
  self.metadata_tags = metadata_tags
87
90
 
91
+ # Legacy mode flag (deprecated)
92
+ self.legacy_patching = legacy_patching
93
+
88
94
  # Runtime state
89
95
  self.temp_dir: Optional[Path] = None
90
96
  self.recording_context: Optional[RecordingContext] = None
@@ -117,9 +123,17 @@ class EpiRecorderSession:
117
123
  set_recording_context(self.recording_context)
118
124
  _thread_local.active_session = self
119
125
 
120
- # Patch LLM libraries and HTTP
121
- from epi_recorder.patcher import patch_all
122
- patch_all()
126
+ # Only patch LLM libraries if legacy mode is enabled (deprecated)
127
+ if self.legacy_patching:
128
+ import warnings
129
+ warnings.warn(
130
+ "legacy_patching is deprecated and will be removed in v3.0.0. "
131
+ "Use epi.log_llm_call() or wrapper clients (wrap_openai) instead.",
132
+ DeprecationWarning,
133
+ stacklevel=2
134
+ )
135
+ from epi_recorder.patcher import patch_all
136
+ patch_all()
123
137
 
124
138
  # Log session start
125
139
  self.log_step("session.start", {
@@ -176,6 +190,11 @@ class EpiRecorderSession:
176
190
  output_path=self.output_path
177
191
  )
178
192
 
193
+ # CRITICAL: Windows file system flush
194
+ # Allow OS to finalize file before signing
195
+ import time
196
+ time.sleep(0.1)
197
+
179
198
  # Sign if requested
180
199
  if self.auto_sign:
181
200
  self._sign_epi_file()
@@ -245,6 +264,172 @@ class EpiRecorderSession:
245
264
  **response_payload
246
265
  })
247
266
 
267
+ def log_llm_call(
268
+ self,
269
+ response: Any,
270
+ messages: Optional[List[Dict[str, str]]] = None,
271
+ provider: str = "auto"
272
+ ) -> None:
273
+ """
274
+ Log a complete LLM call (request + response) from any provider.
275
+
276
+ Auto-detects OpenAI, Anthropic, and Gemini response objects.
277
+ This is the RECOMMENDED way to log LLM calls without monkey patching.
278
+
279
+ Args:
280
+ response: The LLM response object (OpenAI, Anthropic, Gemini, etc.)
281
+ messages: Optional original messages (for request logging)
282
+ provider: Provider name ("auto" to detect, or "openai", "anthropic", etc.)
283
+
284
+ Example:
285
+ with record("my_agent.epi") as epi:
286
+ response = client.chat.completions.create(
287
+ model="gpt-4",
288
+ messages=[{"role": "user", "content": "Hello"}]
289
+ )
290
+ epi.log_llm_call(response, messages=[{"role": "user", "content": "Hello"}])
291
+ """
292
+ if not self._entered:
293
+ raise RuntimeError("Cannot log LLM call outside of context manager")
294
+
295
+ # Auto-detect provider and extract data
296
+ model = "unknown"
297
+ content = ""
298
+ usage = None
299
+ choices = []
300
+
301
+ # Try OpenAI format
302
+ if hasattr(response, "choices") and hasattr(response, "model"):
303
+ provider = "openai" if provider == "auto" else provider
304
+ model = getattr(response, "model", "unknown")
305
+
306
+ for choice in response.choices:
307
+ msg = choice.message
308
+ choices.append({
309
+ "message": {
310
+ "role": getattr(msg, "role", "assistant"),
311
+ "content": getattr(msg, "content", ""),
312
+ },
313
+ "finish_reason": getattr(choice, "finish_reason", None),
314
+ })
315
+ if not content:
316
+ content = getattr(msg, "content", "")
317
+
318
+ if hasattr(response, "usage") and response.usage:
319
+ usage = {
320
+ "prompt_tokens": getattr(response.usage, "prompt_tokens", 0),
321
+ "completion_tokens": getattr(response.usage, "completion_tokens", 0),
322
+ "total_tokens": getattr(response.usage, "total_tokens", 0),
323
+ }
324
+
325
+ # Try Anthropic format
326
+ elif hasattr(response, "content") and hasattr(response, "model"):
327
+ provider = "anthropic" if provider == "auto" else provider
328
+ model = getattr(response, "model", "unknown")
329
+
330
+ # Anthropic returns content as a list of content blocks
331
+ content_blocks = getattr(response, "content", [])
332
+ if content_blocks and hasattr(content_blocks[0], "text"):
333
+ content = content_blocks[0].text
334
+ choices = [{"message": {"role": "assistant", "content": content}}]
335
+
336
+ if hasattr(response, "usage"):
337
+ usage = {
338
+ "input_tokens": getattr(response.usage, "input_tokens", 0),
339
+ "output_tokens": getattr(response.usage, "output_tokens", 0),
340
+ }
341
+
342
+ # Try Gemini format
343
+ elif hasattr(response, "text") and hasattr(response, "candidates"):
344
+ provider = "gemini" if provider == "auto" else provider
345
+ model = "gemini"
346
+ content = getattr(response, "text", "")
347
+ choices = [{"message": {"role": "assistant", "content": content}}]
348
+
349
+ # Fallback: try to extract as dict or string
350
+ else:
351
+ provider = provider if provider != "auto" else "unknown"
352
+ if isinstance(response, dict):
353
+ content = str(response.get("content", response))
354
+ else:
355
+ content = str(response)
356
+ choices = [{"message": {"role": "assistant", "content": content}}]
357
+
358
+ # Log request if messages provided
359
+ if messages:
360
+ self.log_step("llm.request", {
361
+ "provider": provider,
362
+ "model": model,
363
+ "messages": messages,
364
+ "timestamp": datetime.utcnow().isoformat(),
365
+ })
366
+
367
+ # Log response
368
+ response_data = {
369
+ "provider": provider,
370
+ "model": model,
371
+ "choices": choices,
372
+ "timestamp": datetime.utcnow().isoformat(),
373
+ }
374
+ if usage:
375
+ response_data["usage"] = usage
376
+
377
+ self.log_step("llm.response", response_data)
378
+
379
+ def log_chat(
380
+ self,
381
+ model: str,
382
+ messages: List[Dict[str, str]],
383
+ response_content: str,
384
+ provider: str = "custom",
385
+ usage: Optional[Dict[str, int]] = None,
386
+ **metadata
387
+ ) -> None:
388
+ """
389
+ Simplified logging for chat completions.
390
+
391
+ Use this when you have the raw data instead of response objects.
392
+
393
+ Args:
394
+ model: Model name (e.g., "gpt-4", "claude-3")
395
+ messages: The messages sent to the model
396
+ response_content: The assistant's response text
397
+ provider: Provider name (default: "custom")
398
+ usage: Optional token usage dict
399
+ **metadata: Additional metadata to include
400
+
401
+ Example:
402
+ epi.log_chat(
403
+ model="gpt-4",
404
+ messages=[{"role": "user", "content": "Hello"}],
405
+ response_content="Hi there!",
406
+ tokens=150
407
+ )
408
+ """
409
+ if not self._entered:
410
+ raise RuntimeError("Cannot log chat outside of context manager")
411
+
412
+ # Log request
413
+ self.log_step("llm.request", {
414
+ "provider": provider,
415
+ "model": model,
416
+ "messages": messages,
417
+ "timestamp": datetime.utcnow().isoformat(),
418
+ **metadata
419
+ })
420
+
421
+ # Log response
422
+ response_data = {
423
+ "provider": provider,
424
+ "model": model,
425
+ "choices": [{"message": {"role": "assistant", "content": response_content}}],
426
+ "timestamp": datetime.utcnow().isoformat(),
427
+ }
428
+ if usage:
429
+ response_data["usage"] = usage
430
+
431
+ self.log_step("llm.response", response_data)
432
+
248
433
  def log_artifact(
249
434
  self,
250
435
  file_path: Path,
@@ -355,7 +540,24 @@ class EpiRecorderSession:
355
540
  encoding="utf-8"
356
541
  )
357
542
 
358
- # Repack the ZIP with signed manifest
543
+ # Regenerate viewer.html with signed manifest
544
+ steps = []
545
+ steps_file = tmp_path / "steps.jsonl"
546
+ if steps_file.exists():
547
+ for line in steps_file.read_text(encoding="utf-8").strip().split("\n"):
548
+ if line:
549
+ try:
550
+ steps.append(json.loads(line))
551
+ except json.JSONDecodeError:
552
+ pass
553
+
554
+ # Regenerate viewer with signed manifest
555
+ from epi_core.container import EPIContainer
556
+ viewer_html = EPIContainer._create_embedded_viewer(tmp_path, signed_manifest)
557
+ viewer_path = tmp_path / "viewer.html"
558
+ viewer_path.write_text(viewer_html, encoding="utf-8")
559
+
560
+ # Repack the ZIP with signed manifest and updated viewer
359
561
  # CRITICAL: Write to temp file first to prevent data loss
360
562
  temp_output = self.output_path.with_suffix('.epi.tmp')
361
563
 
@@ -590,4 +792,8 @@ def get_current_session() -> Optional[EpiRecorderSession]:
590
792
  Returns:
591
793
  EpiRecorderSession or None
592
794
  """
593
- return getattr(_thread_local, 'active_session', None)
795
+ return getattr(_thread_local, 'active_session', None)
796
+
797
+
798
+
799
+