omni-cortex 1.6.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 (24) hide show
  1. omni_cortex-1.6.0.data/data/share/omni-cortex/dashboard/backend/.env.example +22 -0
  2. omni_cortex-1.6.0.data/data/share/omni-cortex/dashboard/backend/backfill_summaries.py +280 -0
  3. omni_cortex-1.6.0.data/data/share/omni-cortex/dashboard/backend/chat_service.py +315 -0
  4. omni_cortex-1.6.0.data/data/share/omni-cortex/dashboard/backend/database.py +1093 -0
  5. omni_cortex-1.6.0.data/data/share/omni-cortex/dashboard/backend/image_service.py +549 -0
  6. omni_cortex-1.6.0.data/data/share/omni-cortex/dashboard/backend/logging_config.py +122 -0
  7. omni_cortex-1.6.0.data/data/share/omni-cortex/dashboard/backend/main.py +1124 -0
  8. omni_cortex-1.6.0.data/data/share/omni-cortex/dashboard/backend/models.py +241 -0
  9. omni_cortex-1.6.0.data/data/share/omni-cortex/dashboard/backend/project_config.py +170 -0
  10. omni_cortex-1.6.0.data/data/share/omni-cortex/dashboard/backend/project_scanner.py +164 -0
  11. omni_cortex-1.6.0.data/data/share/omni-cortex/dashboard/backend/prompt_security.py +111 -0
  12. omni_cortex-1.6.0.data/data/share/omni-cortex/dashboard/backend/pyproject.toml +23 -0
  13. omni_cortex-1.6.0.data/data/share/omni-cortex/dashboard/backend/security.py +104 -0
  14. omni_cortex-1.6.0.data/data/share/omni-cortex/dashboard/backend/uv.lock +1110 -0
  15. omni_cortex-1.6.0.data/data/share/omni-cortex/dashboard/backend/websocket_manager.py +104 -0
  16. omni_cortex-1.6.0.data/data/share/omni-cortex/hooks/post_tool_use.py +335 -0
  17. omni_cortex-1.6.0.data/data/share/omni-cortex/hooks/pre_tool_use.py +333 -0
  18. omni_cortex-1.6.0.data/data/share/omni-cortex/hooks/stop.py +184 -0
  19. omni_cortex-1.6.0.data/data/share/omni-cortex/hooks/subagent_stop.py +120 -0
  20. omni_cortex-1.6.0.dist-info/METADATA +319 -0
  21. omni_cortex-1.6.0.dist-info/RECORD +24 -0
  22. omni_cortex-1.6.0.dist-info/WHEEL +4 -0
  23. omni_cortex-1.6.0.dist-info/entry_points.txt +4 -0
  24. omni_cortex-1.6.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,104 @@
1
+ """WebSocket manager for real-time updates."""
2
+
3
+ import asyncio
4
+ import json
5
+ from datetime import datetime
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from fastapi import WebSocket
10
+
11
+
12
+ class WebSocketManager:
13
+ """Manages WebSocket connections and broadcasts."""
14
+
15
+ def __init__(self):
16
+ self.connections: dict[str, WebSocket] = {}
17
+ self._lock = asyncio.Lock()
18
+
19
+ async def connect(self, websocket: WebSocket, client_id: str | None = None) -> str:
20
+ """Accept a new WebSocket connection."""
21
+ await websocket.accept()
22
+ client_id = client_id or str(uuid4())
23
+ async with self._lock:
24
+ self.connections[client_id] = websocket
25
+ print(f"[WS] Client connected: {client_id} (total: {len(self.connections)})")
26
+ return client_id
27
+
28
+ async def disconnect(self, client_id: str):
29
+ """Remove a WebSocket connection."""
30
+ async with self._lock:
31
+ if client_id in self.connections:
32
+ del self.connections[client_id]
33
+ print(f"[WS] Client disconnected: {client_id} (total: {len(self.connections)})")
34
+
35
+ async def broadcast(self, event_type: str, data: dict[str, Any]):
36
+ """Broadcast a message to all connected clients."""
37
+ if not self.connections:
38
+ return
39
+
40
+ message = json.dumps({
41
+ "event_type": event_type,
42
+ "data": data,
43
+ "timestamp": datetime.now().isoformat(),
44
+ })
45
+
46
+ disconnected = []
47
+ async with self._lock:
48
+ for client_id, websocket in self.connections.items():
49
+ try:
50
+ await websocket.send_text(message)
51
+ except Exception as e:
52
+ print(f"[WS] Failed to send to {client_id}: {e}")
53
+ disconnected.append(client_id)
54
+
55
+ # Clean up disconnected clients
56
+ for client_id in disconnected:
57
+ del self.connections[client_id]
58
+
59
+ async def send_to_client(self, client_id: str, event_type: str, data: dict[str, Any]):
60
+ """Send a message to a specific client."""
61
+ message = json.dumps({
62
+ "event_type": event_type,
63
+ "data": data,
64
+ "timestamp": datetime.now().isoformat(),
65
+ })
66
+
67
+ async with self._lock:
68
+ if client_id in self.connections:
69
+ try:
70
+ await self.connections[client_id].send_text(message)
71
+ except Exception as e:
72
+ print(f"[WS] Failed to send to {client_id}: {e}")
73
+ del self.connections[client_id]
74
+
75
+ @property
76
+ def connection_count(self) -> int:
77
+ """Get the number of active connections."""
78
+ return len(self.connections)
79
+
80
+ # Typed broadcast methods (IndyDevDan pattern)
81
+ async def broadcast_activity_logged(self, project: str, activity: dict[str, Any]):
82
+ """Broadcast when a new activity is logged."""
83
+ await self.broadcast("activity_logged", {
84
+ "project": project,
85
+ "activity": activity,
86
+ })
87
+
88
+ async def broadcast_session_updated(self, project: str, session: dict[str, Any]):
89
+ """Broadcast when a session is updated."""
90
+ await self.broadcast("session_updated", {
91
+ "project": project,
92
+ "session": session,
93
+ })
94
+
95
+ async def broadcast_stats_updated(self, project: str, stats: dict[str, Any]):
96
+ """Broadcast when stats change (for charts/panels)."""
97
+ await self.broadcast("stats_updated", {
98
+ "project": project,
99
+ "stats": stats,
100
+ })
101
+
102
+
103
+ # Global manager instance
104
+ manager = WebSocketManager()
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse hook - logs tool result after execution.
3
+
4
+ This hook is called by Claude Code after each tool completes.
5
+ It logs the tool output, duration, and success/error status.
6
+
7
+ Hook configuration for settings.json:
8
+ {
9
+ "hooks": {
10
+ "PostToolUse": [
11
+ {
12
+ "type": "command",
13
+ "command": "python hooks/post_tool_use.py"
14
+ }
15
+ ]
16
+ }
17
+ }
18
+ """
19
+
20
+ import json
21
+ import re
22
+ import sys
23
+ import os
24
+ import sqlite3
25
+ import time
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+
31
+ # Session timeout in seconds (4 hours of inactivity = new session)
32
+ SESSION_TIMEOUT_SECONDS = 4 * 60 * 60
33
+
34
+ # Patterns for sensitive field names that should be redacted
35
+ SENSITIVE_FIELD_PATTERNS = [
36
+ r'(?i)(api[_-]?key|apikey)',
37
+ r'(?i)(password|passwd|pwd)',
38
+ r'(?i)(secret|token|credential)',
39
+ r'(?i)(auth[_-]?token|access[_-]?token)',
40
+ r'(?i)(private[_-]?key|ssh[_-]?key)',
41
+ ]
42
+
43
+
44
+ def generate_session_id() -> str:
45
+ """Generate a unique session ID matching the MCP format."""
46
+ timestamp_ms = int(time.time() * 1000)
47
+ random_hex = os.urandom(4).hex()
48
+ return f"sess_{timestamp_ms}_{random_hex}"
49
+
50
+
51
+ def get_session_file_path() -> Path:
52
+ """Get the path to the current session file."""
53
+ project_path = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
54
+ return Path(project_path) / ".omni-cortex" / "current_session.json"
55
+
56
+
57
+ def load_session_file() -> Optional[dict]:
58
+ """Load the current session from file if it exists and is valid."""
59
+ session_file = get_session_file_path()
60
+ if not session_file.exists():
61
+ return None
62
+
63
+ try:
64
+ with open(session_file, "r") as f:
65
+ return json.load(f)
66
+ except (json.JSONDecodeError, IOError):
67
+ return None
68
+
69
+
70
+ def save_session_file(session_data: dict) -> None:
71
+ """Save the current session to file."""
72
+ session_file = get_session_file_path()
73
+ session_file.parent.mkdir(parents=True, exist_ok=True)
74
+
75
+ with open(session_file, "w") as f:
76
+ json.dump(session_data, f, indent=2)
77
+
78
+
79
+ def is_session_valid(session_data: dict) -> bool:
80
+ """Check if a session is still valid (not timed out)."""
81
+ last_activity = session_data.get("last_activity_at")
82
+ if not last_activity:
83
+ return False
84
+
85
+ try:
86
+ last_time = datetime.fromisoformat(last_activity.replace("Z", "+00:00"))
87
+ now = datetime.now(timezone.utc)
88
+ elapsed_seconds = (now - last_time).total_seconds()
89
+ return elapsed_seconds < SESSION_TIMEOUT_SECONDS
90
+ except (ValueError, TypeError):
91
+ return False
92
+
93
+
94
+ def create_session_in_db(conn: sqlite3.Connection, session_id: str, project_path: str) -> None:
95
+ """Create a new session record in the database."""
96
+ cursor = conn.cursor()
97
+ now = datetime.now(timezone.utc).isoformat()
98
+
99
+ # Check if sessions table exists (it might not if only activities table was created)
100
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'")
101
+ if cursor.fetchone() is None:
102
+ # Create sessions table with minimal schema
103
+ conn.executescript("""
104
+ CREATE TABLE IF NOT EXISTS sessions (
105
+ id TEXT PRIMARY KEY,
106
+ project_path TEXT NOT NULL,
107
+ started_at TEXT NOT NULL,
108
+ ended_at TEXT,
109
+ summary TEXT,
110
+ tags TEXT,
111
+ metadata TEXT
112
+ );
113
+ CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
114
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path);
115
+ """)
116
+ conn.commit()
117
+
118
+ cursor.execute(
119
+ """
120
+ INSERT OR IGNORE INTO sessions (id, project_path, started_at)
121
+ VALUES (?, ?, ?)
122
+ """,
123
+ (session_id, project_path, now),
124
+ )
125
+ conn.commit()
126
+
127
+
128
+ def get_or_create_session(conn: sqlite3.Connection, project_path: str) -> str:
129
+ """Get the current session ID, creating a new one if needed.
130
+
131
+ Session management logic:
132
+ 1. Check for existing session file
133
+ 2. If exists and not timed out, use it and update last_activity
134
+ 3. If doesn't exist or timed out, create new session
135
+
136
+ Returns:
137
+ The session ID to use for activity logging
138
+ """
139
+ session_data = load_session_file()
140
+ now_iso = datetime.now(timezone.utc).isoformat()
141
+
142
+ if session_data and is_session_valid(session_data):
143
+ # Update last activity time
144
+ session_data["last_activity_at"] = now_iso
145
+ save_session_file(session_data)
146
+ return session_data["session_id"]
147
+
148
+ # Create new session
149
+ session_id = generate_session_id()
150
+
151
+ # Create in database
152
+ create_session_in_db(conn, session_id, project_path)
153
+
154
+ # Save to file
155
+ session_data = {
156
+ "session_id": session_id,
157
+ "project_path": project_path,
158
+ "started_at": now_iso,
159
+ "last_activity_at": now_iso,
160
+ }
161
+ save_session_file(session_data)
162
+
163
+ return session_id
164
+
165
+
166
+ def redact_sensitive_fields(data: dict) -> dict:
167
+ """Redact sensitive fields from a dictionary for safe logging.
168
+
169
+ Recursively processes nested dicts and lists.
170
+ """
171
+ if not isinstance(data, dict):
172
+ return data
173
+
174
+ result = {}
175
+ for key, value in data.items():
176
+ # Check if key matches sensitive patterns
177
+ is_sensitive = any(
178
+ re.search(pattern, str(key))
179
+ for pattern in SENSITIVE_FIELD_PATTERNS
180
+ )
181
+
182
+ if is_sensitive:
183
+ result[key] = '[REDACTED]'
184
+ elif isinstance(value, dict):
185
+ result[key] = redact_sensitive_fields(value)
186
+ elif isinstance(value, list):
187
+ result[key] = [
188
+ redact_sensitive_fields(item) if isinstance(item, dict) else item
189
+ for item in value
190
+ ]
191
+ else:
192
+ result[key] = value
193
+
194
+ return result
195
+
196
+
197
+ def get_db_path() -> Path:
198
+ """Get the database path for the current project."""
199
+ project_path = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
200
+ return Path(project_path) / ".omni-cortex" / "cortex.db"
201
+
202
+
203
+ def ensure_database(db_path: Path) -> sqlite3.Connection:
204
+ """Ensure database exists and is initialized.
205
+
206
+ Auto-creates the database and schema if it doesn't exist.
207
+ This enables 'out of the box' functionality.
208
+ """
209
+ db_path.parent.mkdir(parents=True, exist_ok=True)
210
+ conn = sqlite3.connect(str(db_path))
211
+
212
+ # Check if schema exists
213
+ cursor = conn.cursor()
214
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='activities'")
215
+ if cursor.fetchone() is None:
216
+ # Apply minimal schema for activities (full schema applied by MCP)
217
+ conn.executescript("""
218
+ CREATE TABLE IF NOT EXISTS activities (
219
+ id TEXT PRIMARY KEY,
220
+ session_id TEXT,
221
+ agent_id TEXT,
222
+ timestamp TEXT NOT NULL,
223
+ event_type TEXT NOT NULL,
224
+ tool_name TEXT,
225
+ tool_input TEXT,
226
+ tool_output TEXT,
227
+ duration_ms INTEGER,
228
+ success INTEGER DEFAULT 1,
229
+ error_message TEXT,
230
+ project_path TEXT,
231
+ file_path TEXT,
232
+ metadata TEXT
233
+ );
234
+ CREATE INDEX IF NOT EXISTS idx_activities_timestamp ON activities(timestamp DESC);
235
+ CREATE INDEX IF NOT EXISTS idx_activities_tool ON activities(tool_name);
236
+ """)
237
+ conn.commit()
238
+
239
+ return conn
240
+
241
+
242
+ def generate_id() -> str:
243
+ """Generate a unique activity ID."""
244
+ timestamp_ms = int(datetime.now().timestamp() * 1000)
245
+ random_hex = os.urandom(4).hex()
246
+ return f"act_{timestamp_ms}_{random_hex}"
247
+
248
+
249
+ def truncate(text: str, max_length: int = 10000) -> str:
250
+ """Truncate text to max length."""
251
+ if len(text) <= max_length:
252
+ return text
253
+ return text[:max_length - 20] + "\n... [truncated]"
254
+
255
+
256
+ def main():
257
+ """Process PostToolUse hook."""
258
+ try:
259
+ # Read all input at once (more reliable than json.load on stdin)
260
+ raw_input = sys.stdin.read()
261
+ if not raw_input or not raw_input.strip():
262
+ print(json.dumps({}))
263
+ return
264
+
265
+ input_data = json.loads(raw_input)
266
+
267
+ # Extract data from hook input
268
+ tool_name = input_data.get("tool_name")
269
+ tool_input = input_data.get("tool_input", {})
270
+ tool_output = input_data.get("tool_output", {})
271
+ agent_id = input_data.get("agent_id")
272
+
273
+ # Determine success/error
274
+ is_error = input_data.get("is_error", False)
275
+ error_message = None
276
+ if is_error and isinstance(tool_output, dict):
277
+ error_message = tool_output.get("error") or tool_output.get("message")
278
+
279
+ # Skip logging our own tools to prevent recursion
280
+ # MCP tools are named like "mcp__omni-cortex__cortex_remember"
281
+ if tool_name and ("cortex_" in tool_name or "omni-cortex" in tool_name):
282
+ print(json.dumps({}))
283
+ return
284
+
285
+ project_path = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
286
+
287
+ # Auto-initialize database (creates if not exists)
288
+ db_path = get_db_path()
289
+ conn = ensure_database(db_path)
290
+
291
+ # Get or create session (auto-manages session lifecycle)
292
+ session_id = get_or_create_session(conn, project_path)
293
+
294
+ # Redact sensitive fields before logging
295
+ safe_input = redact_sensitive_fields(tool_input) if isinstance(tool_input, dict) else tool_input
296
+ safe_output = redact_sensitive_fields(tool_output) if isinstance(tool_output, dict) else tool_output
297
+
298
+ # Insert activity record
299
+ cursor = conn.cursor()
300
+ cursor.execute(
301
+ """
302
+ INSERT INTO activities (
303
+ id, session_id, agent_id, timestamp, event_type,
304
+ tool_name, tool_input, tool_output, success, error_message, project_path
305
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
306
+ """,
307
+ (
308
+ generate_id(),
309
+ session_id,
310
+ agent_id,
311
+ datetime.now(timezone.utc).isoformat(),
312
+ "post_tool_use",
313
+ tool_name,
314
+ truncate(json.dumps(safe_input, default=str)),
315
+ truncate(json.dumps(safe_output, default=str)),
316
+ 0 if is_error else 1,
317
+ error_message,
318
+ project_path,
319
+ ),
320
+ )
321
+ conn.commit()
322
+ conn.close()
323
+
324
+ # Return empty response (no modification)
325
+ print(json.dumps({}))
326
+
327
+ except Exception as e:
328
+ # Hooks should never block - log error but continue
329
+ print(json.dumps({"systemMessage": f"Cortex post_tool_use: {e}"}))
330
+
331
+ sys.exit(0)
332
+
333
+
334
+ if __name__ == "__main__":
335
+ main()