Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ OPENAI_API_KEY=your_openai_api_key_here
# Required if LLM_BACKEND=gemini
GEMINI_API_KEY=your_gemini_api_key_here

# =========================
# NOTION CONFIGURATION
# =========================
NOTION_API_KEY=
NOTION_PARENT_PAGE_ID=


# =========================
# REDIS CONFIGURATION
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ The SvelteKit dashboard provides a futuristic UI showing real-time telemetry, li
- ⚠️ Uncertainty flagging
- 🐒 Safe Mode | ⚑ Expert Mode

### πŸ”— 7. Integrations & Export
- πŸ“ Export session traces directly to **Notion**
- πŸ”— Auto-generate shareable execution reports
- πŸ’Ύ Persistent AI action logs

</td>
</tr>
</table>
Expand Down Expand Up @@ -648,6 +653,19 @@ npm install
cd ..
```

### Notion Integration Setup (Optional)

To enable exporting your Execra sessions to a Notion page, you need to configure your API keys and grant access to your workspace:

1. Go to [Notion Integrations](https://www.notion.so/my-integrations) and create a new internal integration.
2. Copy the **Internal Integration Secret**.
3. Create a `.env` file in the root directory (you can copy from `.env.example`).
4. Set the `NOTION_API_KEY` variable in your `.env` file to the integration secret you copied.
5. Create a target page in your Notion workspace to store the session logs.
6. Copy the **Page ID** from the page URL (the 32-character string at the end) and set it as `NOTION_PARENT_PAGE_ID` in your `.env` file.
7. **Crucial Step:** Open the target page in Notion. Click the **`...` (three dots)** menu in the top right corner, click on **Add connections**, search for your integration's name, and click on it to grant the integration content access to this page. Without this, exports will fail with a 404 error.


### Running the Services Locally

#### 1. Start the FastAPI Backend
Expand Down Expand Up @@ -820,6 +838,9 @@ F --> G

---

> [!IMPORTANT]
> **Please read our full [Contribution Guidelines (CONTRIBUTING.md)](CONTRIBUTING.md)** before writing any code. It contains essential details on local setup, our branching strategy, coding standards, and how GSSoC points are awarded.

### πŸ“ Step-by-Step Contribution Guide

```bash
Expand Down
5 changes: 4 additions & 1 deletion api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
from fastapi.middleware.cors import CORSMiddleware

from api.routes import status, mode
from api.routes import actions, context
from api.routes import actions, context, session
from api.websockets import guidance as ws_guidance
from api.websockets import router as ws_router

from core.config import settings
from core.errors import handle_exception # βœ… NEW
Expand Down Expand Up @@ -74,9 +75,11 @@ def read_root():
# Action log and session context endpoints
app.include_router(actions.router, prefix="/api/v1")
app.include_router(context.router, prefix="/api/v1")
app.include_router(session.router, prefix="/api/v1")

# WebSocket endpoints (no prefix β€” WS routes use the path as-is)
app.include_router(ws_guidance.router)
app.include_router(ws_router.router)

# Alert suppression endpoints
app.include_router(suppression.router, prefix="/api/v1")
30 changes: 30 additions & 0 deletions api/routes/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from core.hybrid.action_logger import action_logger
from core.integrations.notion_exporter import NotionExporter
from core.config import settings
import logging

logger = logging.getLogger(__name__)

router = APIRouter()

class ExportSessionRequest(BaseModel):
session_id: str

@router.post("/session/export/notion")
async def export_session_to_notion(request: ExportSessionRequest):
if not settings.NOTION_API_KEY or not settings.NOTION_PARENT_PAGE_ID:
raise HTTPException(status_code=500, detail="Notion configuration is missing (NOTION_API_KEY or NOTION_PARENT_PAGE_ID)")

actions = await action_logger.get_actions_by_session(request.session_id)
if not actions:
raise HTTPException(status_code=404, detail=f"No actions found for session_id: {request.session_id}")

exporter = NotionExporter(settings.NOTION_API_KEY)
try:
url = exporter.export_session(request.session_id, settings.NOTION_PARENT_PAGE_ID, actions)
return {"message": "Session exported successfully to Notion", "url": url}
except Exception as e:
logger.error(f"Failed to export session to Notion: {e}")
raise HTTPException(status_code=500, detail=f"Failed to export session: {str(e)}")
6 changes: 6 additions & 0 deletions core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class Settings:
LLM_BACKEND: str = "openai"
OPENAI_API_KEY: str = ""
GEMINI_API_KEY: str = ""
NOTION_API_KEY: str = ""
NOTION_PARENT_PAGE_ID: str = ""

# Security
ENCRYPTION_KEY: str = ""
Expand Down Expand Up @@ -90,6 +92,10 @@ def __post_init__(self):
self.OPENAI_API_KEY = val
if val := os.getenv("GEMINI_API_KEY"):
self.GEMINI_API_KEY = val
if val := os.getenv("NOTION_API_KEY"):
self.NOTION_API_KEY = val
if val := os.getenv("NOTION_PARENT_PAGE_ID"):
self.NOTION_PARENT_PAGE_ID = val
# Security
if val := os.getenv("ENCRYPTION_KEY"):
self.ENCRYPTION_KEY = val
Expand Down
26 changes: 26 additions & 0 deletions core/hybrid/action_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,32 @@ async def get_history(self, limit: int = 20, offset: int = 0) -> list[ActionReco
)
for row in rows
]

async def get_actions_by_session(self, session_id: str) -> list[ActionRecord]:
"""Fetch all actions for a specific session_id, ordered by timestamp ascending."""
await self._init_db()

async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute("""
SELECT * FROM action_log
WHERE session_id = ?
ORDER BY timestamp ASC
""", (session_id,))
rows = await cursor.fetchall()

return [
ActionRecord(
id=row[0],
session_id=row[1],
timestamp=datetime.fromisoformat(row[2]),
type=row[3],
description=row[4],
domain=row[5],
was_guided=bool(row[6]),
guidance_confidence=row[7]
)
for row in rows
]
async def clear_session(self, session_id: str) -> None:
"""Delete all actions for the session from SQLite and clear the in-memory stack."""
await self._init_db() # ensure table exists
Expand Down
86 changes: 86 additions & 0 deletions core/integrations/notion_exporter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import logging
from typing import List
from notion_client import Client
from core.hybrid.action_logger import ActionRecord

logger = logging.getLogger(__name__)

class NotionExporter:
def __init__(self, api_key: str):
self.client = Client(auth=api_key)

def _create_table_row(self, cells: List[str]) -> dict:
"""Create a Notion table row block from a list of cell strings."""
return {
"type": "table_row",
"table_row": {
"cells": [
[{"type": "text", "text": {"content": str(cell)[:2000]}}] for cell in cells
]
}
}

def export_session(self, session_id: str, page_id: str, actions: List[ActionRecord]) -> str:
"""
Export a session's actions to a new Notion page.
Returns the URL of the created page.
"""
page_properties = {
"title": [
{
"text": {
"content": f"Execra Session: {session_id}"
}
}
]
}

# Header row
headers = ["Timestamp", "Action Type", "Description", "Domain", "Guided", "Confidence"]

# Notion API limits block children to 100 elements.
# A table block can have max 100 rows. We reserve 1 for header.
CHUNK_SIZE = 99

blocks = []
for i in range(0, len(actions), CHUNK_SIZE):
chunk = actions[i:i + CHUNK_SIZE]

table_children = [self._create_table_row(headers)]
for action in chunk:
confidence_str = f"{action.guidance_confidence:.2f}" if action.guidance_confidence is not None else "N/A"
row_data = [
action.timestamp.isoformat(),
action.type,
action.description,
action.domain,
"Yes" if action.was_guided else "No",
confidence_str
]
table_children.append(self._create_table_row(row_data))

table_block = {
"object": "block",
"type": "table",
"table": {
"table_width": 6,
"has_column_header": True,
"has_row_header": False,
"children": table_children
}
}
blocks.append(table_block)

try:
response = self.client.pages.create(
parent={"page_id": page_id},
properties=page_properties,
children=blocks
)
new_page = dict(response) if isinstance(response, dict) else getattr(response, "__dict__", {})
page_url = new_page.get('url', "")
logger.info(f"Successfully exported session {session_id} to Notion page: {page_url}")
return page_url
except Exception as e:
logger.error(f"Failed to export session {session_id} to Notion: {e}")
raise
112 changes: 112 additions & 0 deletions dashboard/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
let historyError = $state<string | null>(null);
let isSendingAction = $state(false);
let isUndoingAction = $state(false);
let isExportingNotion = $state(false);
let exportSuccessUrl = $state<string | null>(null);
let exportErrorMsg = $state<string | null>(null);

// Form inputs for simulating a new action
let simType = $state('user_click');
Expand Down Expand Up @@ -97,6 +100,42 @@
}
}

// Export session to Notion via POST /api/v1/session/export/notion
async function handleExportNotion() {
if (isExportingNotion) return;
try {
isExportingNotion = true;
exportSuccessUrl = null;
exportErrorMsg = null;
const actions = allActions();
const currentSessionId = actions.length > 0 ? actions[0].session_id : null;

if (!currentSessionId) {
exportErrorMsg = 'No active session to export. Simulate an action first.';
return;
}

const res = await fetch('http://127.0.0.1:8000/api/v1/session/export/notion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: currentSessionId })
});

const data = await res.json();

if (!res.ok) {
exportErrorMsg = data.detail || res.statusText;
} else {
exportSuccessUrl = data.url;
}
} catch (err) {
console.error('Failed to export to Notion:', err);
exportErrorMsg = 'Network error while exporting to Notion. Please check your connection.';
} finally {
isExportingNotion = false;
}
}

onMount(() => {
wsService.connect();
fetchHistory();
Expand Down Expand Up @@ -446,6 +485,24 @@
REFRESH DB
</button>
</div>

<div class="flex items-center justify-between p-3 bg-[#0b1020]/60 border border-slate-800 rounded-md">
<div class="text-xs">
<p class="font-bold text-slate-300">Export to Notion</p>
<p class="text-[10px] text-slate-500 mt-0.5">Sync session actions to Notion page</p>
</div>
<button
onclick={handleExportNotion}
disabled={isExportingNotion}
class="bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 text-xs px-3.5 py-1.5 rounded font-semibold transition cursor-pointer disabled:opacity-50"
>
{#if isExportingNotion}
EXPORTING...
{:else}
EXPORT NOW
{/if}
</button>
</div>
</div>
</div>

Expand All @@ -472,4 +529,59 @@
</div>
</section>
</main>

<!-- Notion Export Success Modal -->
{#if exportSuccessUrl}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm transition-opacity">
<div class="bg-[#111827] border border-slate-700 rounded-xl shadow-2xl max-w-md w-full mx-4 overflow-hidden transform transition-all">
<div class="p-6 text-center">
<div class="w-16 h-16 bg-emerald-500/20 text-emerald-400 rounded-full flex items-center justify-center mx-auto mb-4 border border-emerald-500/30">
<svg class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 class="text-xl font-bold text-white mb-2 tracking-wide">Export Successful!</h3>
<p class="text-slate-400 text-sm mb-6">Your Execra session has been securely synced to Notion.</p>

<div class="bg-[#0b1020] rounded-lg p-4 border border-slate-800 mb-6 flex flex-col space-y-3">
<span class="text-[10px] uppercase tracking-widest font-bold text-slate-500">Notion Page Link</span>
<a href={exportSuccessUrl} target="_blank" rel="noopener noreferrer" class="text-emerald-400 font-mono text-xs break-all hover:underline hover:text-emerald-300">
{exportSuccessUrl}
</a>
</div>

<button
onclick={() => { exportSuccessUrl = null; }}
class="w-full bg-slate-800 hover:bg-slate-700 text-white font-bold uppercase tracking-wider text-xs py-3 rounded-lg transition-colors border border-slate-600 cursor-pointer"
>
CLOSE WINDOW
</button>
</div>
</div>
</div>
{/if}

<!-- Notion Export Error Modal -->
{#if exportErrorMsg}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm transition-opacity">
<div class="bg-[#111827] border border-slate-700 rounded-xl shadow-2xl max-w-md w-full mx-4 overflow-hidden transform transition-all">
<div class="p-6 text-center">
<div class="w-16 h-16 bg-red-500/20 text-red-400 rounded-full flex items-center justify-center mx-auto mb-4 border border-red-500/30">
<svg class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h3 class="text-xl font-bold text-white mb-2 tracking-wide">Export Failed</h3>
<p class="text-slate-400 text-sm mb-6">{exportErrorMsg}</p>

<button
onclick={() => { exportErrorMsg = null; }}
class="w-full bg-slate-800 hover:bg-slate-700 text-white font-bold uppercase tracking-wider text-xs py-3 rounded-lg transition-colors border border-slate-600 cursor-pointer"
>
DISMISS
</button>
</div>
</div>
</div>
{/if}
</div>
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ pydantic
cryptography
scikit-learn
joblib
notion-client
Loading