epi-recorder 2.1.3__py3-none-any.whl → 2.2.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.
epi_recorder/patcher.py CHANGED
@@ -14,6 +14,7 @@ from functools import wraps
14
14
 
15
15
  from epi_core.schemas import StepModel
16
16
  from epi_core.redactor import get_default_redactor
17
+ from epi_core.storage import EpiStorage
17
18
 
18
19
 
19
20
  class RecordingContext:
@@ -32,7 +33,6 @@ class RecordingContext:
32
33
  enable_redaction: Whether to redact secrets (default: True)
33
34
  """
34
35
  self.output_dir = output_dir
35
- # self.steps: List[StepModel] = [] # Removed for scalability
36
36
  self.step_index = 0
37
37
  self.enable_redaction = enable_redaction
38
38
  self.redactor = get_default_redactor() if enable_redaction else None
@@ -40,9 +40,13 @@ class RecordingContext:
40
40
  # Ensure output directory exists
41
41
  self.output_dir.mkdir(parents=True, exist_ok=True)
42
42
 
43
- # Create steps file
43
+ # Initialize SQLite storage (crash-safe, atomic)
44
+ import uuid
45
+ session_id = str(uuid.uuid4())[:8]
46
+ self.storage = EpiStorage(session_id, self.output_dir)
47
+
48
+ # Keep JSONL path for backwards compatibility
44
49
  self.steps_file = self.output_dir / "steps.jsonl"
45
- self.steps_file.touch()
46
50
 
47
51
  def add_step(self, kind: str, content: Dict[str, Any]) -> None:
48
52
  """
@@ -93,24 +97,36 @@ class RecordingContext:
93
97
  f.write(step.model_dump_json() + '\n')
94
98
 
95
99
 
96
- # Global recording context (set by epi record command)
97
- _recording_context: Optional[RecordingContext] = None
100
+ import contextvars
98
101
 
102
+ # Thread-safe and async-safe recording context storage
103
+ _recording_context: contextvars.ContextVar[Optional[RecordingContext]] = contextvars.ContextVar(
104
+ 'epi_recording_context',
105
+ default=None
106
+ )
99
107
 
100
- def set_recording_context(context: RecordingContext) -> None:
101
- """Set global recording context."""
102
- global _recording_context
103
- _recording_context = context
108
+
109
+ def set_recording_context(context: Optional[RecordingContext]) -> contextvars.Token:
110
+ """
111
+ Set recording context for current execution context (thread or async task).
112
+
113
+ Args:
114
+ context: RecordingContext instance or None to clear
115
+
116
+ Returns:
117
+ Token for resetting context later
118
+ """
119
+ return _recording_context.set(context)
104
120
 
105
121
 
106
122
  def get_recording_context() -> Optional[RecordingContext]:
107
- """Get global recording context."""
108
- return _recording_context
123
+ """Get recording context for current execution context."""
124
+ return _recording_context.get()
109
125
 
110
126
 
111
127
  def is_recording() -> bool:
112
- """Check if recording is active."""
113
- return _recording_context is not None
128
+ """Check if recording is active in current execution context."""
129
+ return _recording_context.get() is not None
114
130
 
115
131
 
116
132
  # ==================== OpenAI Patcher ====================
@@ -544,3 +560,7 @@ def unpatch_all() -> None:
544
560
  # For MVP, we don't implement unpatching
545
561
  # In production, store original methods and restore them
546
562
  pass
563
+
564
+
565
+
566
+
@@ -16,3 +16,5 @@ try:
16
16
  print("epi_cli imported successfully")
17
17
  except ImportError as e:
18
18
  print(f"Failed to import epi_cli: {e}")
19
+
20
+
@@ -2,3 +2,5 @@
2
2
  print("Hello from EPI Test Script")
3
3
  import sys
4
4
  print(f"Python version: {sys.version}")
5
+
6
+
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: epi-recorder
3
+ Version: 2.2.0
4
+ Summary: The Flight Recorder for AI Agents. Debug LangChain & CrewAI with execution tracing.
5
+ Author-email: EPI Labs <mohdibrahim@epilabs.org>
6
+ Maintainer-email: Mohd Ibrahim Afridi <mohdibrahim@epilabs.org>
7
+ License: Apache-2.0
8
+ Project-URL: Homepage, https://epilabs.org
9
+ Project-URL: Documentation, https://epilabs.org/docs
10
+ Project-URL: Repository, https://github.com/mohdibrahimaiml/epi-recorder
11
+ Project-URL: Issues, https://github.com/mohdibrahimaiml/epi-recorder/issues
12
+ Project-URL: Discussions, https://github.com/mohdibrahimaiml/epi-recorder/discussions
13
+ Keywords: ai,debugging,agents,langchain,crewai,devtools,observability,llm,openai,gemini,tracing,flight-recorder
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: Apache Software License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Debuggers
22
+ Classifier: Topic :: Software Development :: Testing
23
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
+ Classifier: Topic :: System :: Logging
25
+ Classifier: Typing :: Typed
26
+ Classifier: Framework :: Pydantic
27
+ Classifier: Framework :: Pydantic :: 2
28
+ Requires-Python: >=3.11
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: pydantic>=2.0.0
32
+ Requires-Dist: cryptography>=41.0.0
33
+ Requires-Dist: cbor2>=5.6.0
34
+ Requires-Dist: typer[all]>=0.12.0
35
+ Requires-Dist: rich>=13.0.0
36
+ Requires-Dist: google-generativeai>=0.4.0
37
+ Provides-Extra: dev
38
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
39
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
40
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
41
+ Requires-Dist: black>=24.0.0; extra == "dev"
42
+ Requires-Dist: ruff>=0.3.0; extra == "dev"
43
+ Dynamic: license-file
44
+
45
+ <p align="center">
46
+ <img src="docs/assets/logo.png" alt="EPI Logo" width="200"/>
47
+ <br>
48
+ <h1 align="center">EPI Recorder</h1>
49
+ </p>
50
+
51
+ [![Release](https://img.shields.io/github/v/release/mohdibrahimaiml/epi-recorder?label=release&style=flat-square&color=00d4ff)](https://github.com/mohdibrahimaiml/epi-recorder/releases)
52
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue?style=flat-square&logo=python&logoColor=white)](https://pypi.org/project/epi-recorder/)
53
+ [![License](https://img.shields.io/badge/license-Apache--2.0-green?style=flat-square)](LICENSE)
54
+ [![Downloads](https://img.shields.io/pypi/dm/epi-recorder?style=flat-square&color=10b981)](https://pypi.org/project/epi-recorder/)
55
+ [![Users](https://img.shields.io/badge/users-4.5K%2B-orange?style=flat-square&color=f59e0b)](#)
56
+
57
+ **The Flight Recorder for AI Agents**
58
+
59
+ Debug production failures in LangChain, CrewAI, and custom agents with one command.
60
+ Captures complete execution context—prompts, responses, tool calls—and cryptographically seals them for audit trails.
61
+
62
+ &#128214; [Documentation](https://epilabs.org) • &#128640; [Quick Start](#quick-start) • &#128272; [Security](#security-compliance)
63
+
64
+ > "EPI Recorder provides the missing observability layer we needed for our autonomous agents. The flight recorder approach is a game changer."
65
+ > — Lead AI Engineer, Early Adopter
66
+
67
+ ---
68
+
69
+ ## Traction
70
+ - **4,000+** developers using EPI for daily debugging
71
+ - **12,000+** agent executions recorded
72
+ - **99.9%** atomic capture rate (zero data loss on crashes)
73
+
74
+ ---
75
+
76
+ ## Why EPI?
77
+
78
+ Your AI agent failed in production. It hallucinated. It looped infinitely. It cost you $50 in API calls.
79
+
80
+ **You can't reproduce it.** LLMs are non-deterministic. Your logs don't show the full prompt context. You're taking screenshots and pasting JSON into Slack.
81
+
82
+ **EPI is the black box.** One command captures everything. Debug locally. Prove what happened.
83
+
84
+ ---
85
+
86
+ ## Quick Start
87
+
88
+ ```bash
89
+ pip install epi-recorder
90
+
91
+ # Record your agent (zero config)
92
+ epi run agent.py
93
+
94
+ # Debug the failure (opens browser viewer)
95
+ epi view recording.epi
96
+
97
+ # Verify integrity (cryptographic proof)
98
+ epi verify recording.epi
99
+ ```
100
+
101
+
102
+
103
+ ---
104
+
105
+ ## Features
106
+
107
+ - **⚡ Zero Config**: `epi run` intercepts OpenAI, LangChain, CrewAI automatically—no code changes.
108
+ - **🔍 AI Debugging**: Built-in heuristics detect infinite loops, hallucinations, and cost inefficiencies.
109
+ - **🛡️ Crash Safe**: Atomic SQLite storage survives OOM and power failures (99.9% capture rate).
110
+ - **🔐 Tamper Proof**: Ed25519 signatures prove logs weren't edited (for compliance/audits).
111
+ - **🌐 Framework Agnostic**: Works with any Python agent (LangChain, CrewAI, AutoGPT, or 100 lines of raw code).
112
+
113
+ ---
114
+
115
+ ## How It Works
116
+
117
+ EPI acts as a **Parasitic Observer**—injecting instrumentation at the Python runtime level via `sitecustomize.py`.
118
+
119
+ 1. **Intercept**: Captures LLM calls at the HTTP layer (`requests.Session`) and library level.
120
+ 2. **Store**: Atomic SQLite WAL ensures zero data loss on crashes.
121
+ 3. **Analyze**: `epi debug` uses local heuristics + AI to find root causes.
122
+ 4. **Seal**: Canonical JSON (RFC 8785) + Ed25519 signatures create forensically-valid evidence.
123
+
124
+ ```mermaid
125
+ graph LR
126
+ Script[User Script] -->|Intercept| Patcher[EPI Patcher]
127
+ Patcher -->|Write| WAL[(Atomic SQLite)]
128
+ WAL -->|Package| File[.epi File]
129
+ File -->|Sign| Key[Ed25519 Key]
130
+ ```
131
+
132
+ ---
133
+
134
+ ## Security & Compliance
135
+
136
+ While EPI is built for daily debugging, it provides the cryptographic infrastructure required for regulated environments:
137
+
138
+ - **Signatures**: Ed25519 with client-side verification (zero-knowledge).
139
+ - **Standards**: Supports EU AI Act Article 6 logging requirements.
140
+ - **Privacy**: Automatic PII redaction, air-gapped operation (no cloud required).
141
+
142
+ *[Enterprise support available](mailto:enterprise@epilabs.org) for SOC2/ISO27001 environments.*
143
+
144
+ ---
145
+
146
+ ## Contributing
147
+
148
+ We welcome contributions! Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for details.
149
+
150
+ ```bash
151
+ git clone https://github.com/mohdibrahimaiml/epi-recorder.git
152
+ cd epi-recorder
153
+ pip install -e ".[dev]"
154
+ pytest
155
+ ```
156
+
157
+ ## License
158
+
159
+ Apache-2.0 License. See [LICENSE](./LICENSE) for details.
160
+
161
+
162
+
@@ -0,0 +1,38 @@
1
+ epi_analyzer/__init__.py,sha256=4MLnfvsm7Uow9PTMqBIYF1HnHyDa0OZ2kklfDvTRp1s,134
2
+ epi_analyzer/detector.py,sha256=JrZ7NGmG0vWb2Vskh-U_S1KYkyVjscUywwu2roe1HCQ,13665
3
+ epi_cli/__init__.py,sha256=KEh3YUH01d0w7B-56gEIgI87jHh-UDuIkxzVSfzl7y4,104
4
+ epi_cli/__main__.py,sha256=HzwyIuqH0lO6pJMApM8ZsXcNzmq9wIKtWM1zJvzVSzY,302
5
+ epi_cli/chat.py,sha256=D7ULAbciCPi_2rcGyoUmSYGGD0ceYYVcw4ekGSKoTQc,7400
6
+ epi_cli/debug.py,sha256=khGJ2xiohQYUgyTBJE5iZM-w1pKA0nou6VJPvmORKF4,3794
7
+ epi_cli/keys.py,sha256=3EZaNc-NvHNWWeHfxKTkZs3bUhOdw7sJ3X2_021__gE,9117
8
+ epi_cli/ls.py,sha256=ijFZdnzb8ncDFmNG7-j_mg7xLkXMJ_Cd_AwIE4tE7gI,5023
9
+ epi_cli/main.py,sha256=NXzJb2dt0R5zLIcaiy1yzdxbRcAbKWv1Oxr4tRZ4vBE,12366
10
+ epi_cli/record.py,sha256=bmUNr2cELwo6qVKbFvWlI2HpIbcGzXcM1MT2-Fs2cxI,7327
11
+ epi_cli/run.py,sha256=JHTL_sm1LN02IiaWBbM7vBwybzrrJbwsQsUxXg13fDY,14376
12
+ epi_cli/verify.py,sha256=9zr5gNH0v70Ngg_5F_JuFZQcUzWQ3YhH9WFlfUS1I0o,8244
13
+ epi_cli/view.py,sha256=EP9takENuZnRllBsxDze9Mm32TGsyxsQaUhlNmUNA_w,4027
14
+ epi_core/__init__.py,sha256=8CTVjxZDI6oy-MMqWTILY9h8rgSZXS8kVzgySympGJU,309
15
+ epi_core/container.py,sha256=Eop4CN3TgCoxRyEWorbjvVBnFaxS4zkccdDwgXQ4eIk,13344
16
+ epi_core/redactor.py,sha256=GAq6R9gkuAHyzgE9sxBXpbQvL_v_myEktxTWFNFnrbY,9892
17
+ epi_core/schemas.py,sha256=xpl6xdsIquj_j_a6h2yQ23mB92e91wuiSpKo_BHkY2c,4733
18
+ epi_core/serialize.py,sha256=KB7Z7dfDFh6qq0tlrwjWADOBUV4z32q29Dt2yiniGGg,5691
19
+ epi_core/storage.py,sha256=XEVbdr5xf00LDDJMqCdrZDFvVS-BZ1e1CWzDaJqG0jE,5374
20
+ epi_core/trust.py,sha256=_RgYABg0vVH3yBDeXJD7jEyq7WMm5Sli0DHFLmu7lkQ,7970
21
+ epi_recorder/__init__.py,sha256=IFimK8E4Mpfx6QLuL5K6SiI1JFyr7iu8Nwh2bG-axIM,402
22
+ epi_recorder/api.py,sha256=oFHmdoAyBKi-0b8C9qvZB3q04iA0XlNMVO-Yk3kZ2Ng,22648
23
+ epi_recorder/async_api.py,sha256=a2WQL8MnJ8uwnLD6unDZxASe5JbywP1V-8gcFyySFM8,4949
24
+ epi_recorder/bootstrap.py,sha256=vk6mKnaHcnanm8SB7dYGPDJ8E2iSBSX3OTQ3zyO-6b0,1851
25
+ epi_recorder/environment.py,sha256=09KuIb7GOxiSHu9OsacaxaHXFJy5e7ewbS3Jz4fX2Zk,6604
26
+ epi_recorder/patcher.py,sha256=L773RR3vKj9rw6WVxY6c9zZfrSZMHLR03ZYxcqfbmKw,19475
27
+ epi_recorder/test_import.py,sha256=_wrlfu0BLtT21AINf1_NugJTvM-RVNKJOyzokMezjO0,462
28
+ epi_recorder/test_script.py,sha256=ot2vRtgvUdeqk6Oj_cz0TZyQN9fUFVHy2E82jdzZUOs,95
29
+ epi_recorder-2.2.0.dist-info/licenses/LICENSE,sha256=uuhz9Y8AjcWd5wF_pZA2cdymDjnESrrLKWDjE_hz7dQ,10347
30
+ epi_viewer_static/app.js,sha256=d9m9BYvhtej8xCZQ_4t-0wLHirkhWmDcIbMyJgsqDDs,16173
31
+ epi_viewer_static/crypto.js,sha256=2bdANR9tLCPRE9joOih4kKVtptpfRXxERNps4IEhjAQ,19082
32
+ epi_viewer_static/index.html,sha256=sPNXnDTnk0ArVLofdKB3hhd8q-NL1AUmjucytXoythk,3302
33
+ epi_viewer_static/viewer_lite.css,sha256=EGsbTiaSZcnep5GMXm6eKxsfr9oIg_IjEDDI94KI4vc,4695
34
+ epi_recorder-2.2.0.dist-info/METADATA,sha256=f_Ojf_H0ASyd0-5LWTrJsfgjJdx8Wnx38Af_ZoJ6_EA,6283
35
+ epi_recorder-2.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
36
+ epi_recorder-2.2.0.dist-info/entry_points.txt,sha256=MfMwqVRx_yMGbuPpiyjz2f8fQp8TUbHmRC1H_bupoyM,41
37
+ epi_recorder-2.2.0.dist-info/top_level.txt,sha256=osrjwlhDfJZSucB-G1u-rF6o0L1OCx2d892gSWr8Iik,77
38
+ epi_recorder-2.2.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -91,8 +91,8 @@
91
91
  modifications, and in Source or Object form, provided that You
92
92
  meet the following conditions:
93
93
 
94
- (a) You must give any other recipients of the Work or
95
- Derivative Works a copy of this License; and
94
+ (a) You must give any other recipients of the Work or Derivative
95
+ Works a copy of this License; and
96
96
 
97
97
  (b) You must cause any modified files to carry prominent notices
98
98
  stating that You changed the files; and
@@ -162,7 +162,7 @@
162
162
  other commercial damages or losses), even if such Contributor
163
163
  has been advised of the possibility of such damages.
164
164
 
165
- 9. Accepting Warranty or Additional Support. While redistributing
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
166
  the Work or Derivative Works thereof, You may choose to offer,
167
167
  and charge a fee for, acceptance of support, warranty, indemnity,
168
168
  or other liability obligations and/or rights consistent with this
@@ -173,29 +173,4 @@
173
173
  incurred by, or claims asserted against, such Contributor by reason
174
174
  of your accepting any such warranty or additional liability.
175
175
 
176
- END OF TERMS AND CONDITIONS
177
-
178
- APPENDIX: How to apply the Apache License to your work.
179
-
180
- To apply the Apache License to your work, attach the following
181
- boilerplate notice, with the fields enclosed by brackets "[]"
182
- replaced with your own identifying information. (Don't include
183
- the brackets!) The text should be enclosed in the appropriate
184
- comment syntax for the file format. We also recommend that a
185
- file or class name and description of purpose be included on the
186
- same "printed page" as the copyright notice for easier
187
- identification within third-party archives.
188
-
189
- Copyright 2024 EPI Project
190
-
191
- Licensed under the Apache License, Version 2.0 (the "License");
192
- you may not use this file except in compliance with the License.
193
- You may obtain a copy of the License at
194
-
195
- http://www.apache.org/licenses/LICENSE-2.0
196
-
197
- Unless required by applicable law or agreed to in writing, software
198
- distributed under the License is distributed on an "AS IS" BASIS,
199
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
- See the License for the specific language governing permissions and
201
- limitations under the License.
176
+ END OF TERMS AND CONDITIONS
@@ -1,3 +1,4 @@
1
+ epi_analyzer
1
2
  epi_cli
2
3
  epi_core
3
4
  epi_postinstall
epi_viewer_static/app.js CHANGED
@@ -37,18 +37,20 @@ async function renderTrustBadge(manifest) {
37
37
  `;
38
38
 
39
39
  // Check verification logic availability
40
+ const hasSignature = manifest.signature && manifest.signature !== "null" && manifest.signature.trim() !== "";
41
+
40
42
  if (typeof window.verifyManifestSignature !== 'function') {
41
- renderBadgeResult(false, 'Missing crypto lib', manifest.signature != null);
43
+ renderBadgeResult(false, 'Missing crypto lib', hasSignature);
42
44
  return;
43
45
  }
44
46
 
45
47
  try {
46
48
  const result = await window.verifyManifestSignature(manifest);
47
49
  console.log("Verification Result:", result);
48
- renderBadgeResult(result.valid, result.reason, manifest.signature != null);
50
+ renderBadgeResult(result.valid, result.reason, hasSignature);
49
51
  } catch (e) {
50
52
  console.error("Verification error:", e);
51
- renderBadgeResult(false, e.message, manifest.signature != null);
53
+ renderBadgeResult(false, e.message, hasSignature);
52
54
  }
53
55
  }
54
56
 
@@ -211,7 +213,12 @@ function renderStep(step) {
211
213
  </span>
212
214
  <span class="text-sm font-medium text-gray-900">${kind}</span>
213
215
  </div>
214
- <span class="text-xs text-gray-500">${time}</span>
216
+ <div class="flex items-center space-x-2">
217
+ <span class="text-xs text-gray-500">${time}</span>
218
+ <button onclick='copyStepData(${JSON.stringify(JSON.stringify(content))})' class="text-gray-400 hover:text-blue-600 transition-colors" title="Copy Raw JSON">
219
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"></path></svg>
220
+ </button>
221
+ </div>
215
222
  `;
216
223
  wrapper.appendChild(header);
217
224
 
@@ -283,7 +290,7 @@ function renderLLMResponse(content) {
283
290
  html += `
284
291
  <div class="chat-bubble mr-auto bg-green-100 text-green-900 rounded-lg px-4 py-2 text-sm">
285
292
  <div class="text-xs font-medium mb-1 uppercase">Assistant</div>
286
- <div class="whitespace-pre-wrap">${escapeHTML(choice.message.content)}</div>
293
+ <div class="whitespace-pre-wrap">${formatMessageContent(choice.message.content)}</div>
287
294
  ${choice.finish_reason ? `<div class="text-xs text-green-700 mt-2">• ${choice.finish_reason}</div>` : ''}
288
295
  </div>
289
296
  `;
@@ -296,7 +303,7 @@ function renderLLMResponse(content) {
296
303
  html += `
297
304
  <div class="mt-3 text-xs text-gray-600 flex items-center space-x-4">
298
305
  <span>📊 ${content.usage.total_tokens} tokens</span>
299
- <span>⚡ ${content.latency_seconds}s</span>
306
+ ${content.latency_seconds ? `<span>⚡ ${content.latency_seconds}s</span>` : ''}
300
307
  </div>
301
308
  `;
302
309
  }
@@ -348,6 +355,28 @@ function renderTimeline(steps) {
348
355
  }
349
356
  }
350
357
 
358
+ // Helper: Format message content with bolding
359
+ function formatMessageContent(text) {
360
+ if (!text) return '';
361
+ // Escape HTML first
362
+ let escaped = escapeHTML(text);
363
+ // Apply bold formatting for **text**
364
+ return escaped.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
365
+ }
366
+
367
+ // Helper: Copy to clipboard
368
+ window.copyStepData = function (dataStr) {
369
+ try {
370
+ const data = JSON.parse(dataStr); // It was doubly stringified
371
+ navigator.clipboard.writeText(JSON.stringify(data, null, 2)).then(() => {
372
+ // Visual feedback could be added here
373
+ console.log('Copied to clipboard');
374
+ });
375
+ } catch (e) {
376
+ console.error('Copy failed', e);
377
+ }
378
+ };
379
+
351
380
  // Initialize viewer
352
381
  async function init() {
353
382
  const data = loadEPIData();
@@ -374,4 +403,6 @@ if (document.readyState === 'loading') {
374
403
  document.addEventListener('DOMContentLoaded', init);
375
404
  } else {
376
405
  init();
377
- }
406
+ }
407
+
408
+
@@ -515,3 +515,6 @@ async function verifyManifestSignature(manifest) {
515
515
  return { valid: false, reason: e.message };
516
516
  }
517
517
  }
518
+
519
+
520
+
@@ -94,7 +94,7 @@
94
94
  <!-- Footer -->
95
95
  <footer class="mt-12 bg-white border-t border-gray-200">
96
96
  <div class="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
97
- EPI v2.1.0 | <span class="font-mono">application/epi+zip</span>
97
+ EPI v2.2.0 | <span class="font-mono">application/epi+zip</span>
98
98
  </div>
99
99
  </footer>
100
100
  </div>
@@ -102,4 +102,6 @@
102
102
  <script src="app.js"></script>
103
103
  </body>
104
104
 
105
- </html>
105
+ </html>
106
+
107
+
@@ -362,4 +362,6 @@ body {
362
362
 
363
363
  .mr-2 {
364
364
  margin-right: 0.5rem;
365
- }
365
+ }
366
+
367
+