omni-cortex 1.5.0__tar.gz → 1.6.0__tar.gz
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.
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/PKG-INFO +1 -1
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/chat_service.py +5 -1
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/image_service.py +4 -4
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/main.py +29 -2
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/websocket_manager.py +22 -0
- omni_cortex-1.6.0/hooks/post_tool_use.py +335 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/hooks/pre_tool_use.py +130 -1
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/__init__.py +1 -1
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/tools/memories.py +13 -2
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/utils/formatting.py +43 -6
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/pyproject.toml +1 -1
- omni_cortex-1.5.0/hooks/post_tool_use.py +0 -160
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/.gitignore +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/LICENSE +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/README.md +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/.env.example +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/backfill_summaries.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/database.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/logging_config.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/models.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/project_config.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/project_scanner.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/prompt_security.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/pyproject.toml +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/security.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/uv.lock +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/hooks/stop.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/hooks/subagent_stop.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/categorization/__init__.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/categorization/auto_tags.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/categorization/auto_type.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/config.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/dashboard.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/database/__init__.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/database/connection.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/database/migrations.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/database/schema.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/database/sync.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/decay/__init__.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/decay/importance.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/embeddings/__init__.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/embeddings/local.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/models/__init__.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/models/activity.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/models/agent.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/models/memory.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/models/relationship.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/models/session.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/resources/__init__.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/search/__init__.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/search/hybrid.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/search/keyword.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/search/ranking.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/search/semantic.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/server.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/setup.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/tools/__init__.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/tools/activities.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/tools/sessions.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/tools/utilities.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/utils/__init__.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/utils/ids.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/utils/timestamps.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/utils/truncation.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/scripts/check-venv.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/scripts/import_ken_memories.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/scripts/populate_session_data.py +0 -0
- {omni_cortex-1.5.0 → omni_cortex-1.6.0}/scripts/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: omni-cortex
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.0
|
|
4
4
|
Summary: Give Claude Code a perfect memory - auto-logs everything, searches smartly, and gets smarter over time
|
|
5
5
|
Project-URL: Homepage, https://github.com/AllCytes/Omni-Cortex
|
|
6
6
|
Project-URL: Repository, https://github.com/AllCytes/Omni-Cortex
|
|
@@ -214,9 +214,13 @@ async def save_conversation(
|
|
|
214
214
|
client = get_client()
|
|
215
215
|
if client:
|
|
216
216
|
try:
|
|
217
|
+
# Escape content to prevent injection in summary generation
|
|
218
|
+
safe_content = xml_escape(content[:2000])
|
|
217
219
|
summary_prompt = f"""Summarize this conversation in one concise sentence (max 100 chars):
|
|
218
220
|
|
|
219
|
-
|
|
221
|
+
<conversation>
|
|
222
|
+
{safe_content}
|
|
223
|
+
</conversation>
|
|
220
224
|
|
|
221
225
|
Summary:"""
|
|
222
226
|
response = client.models.generate_content(
|
|
@@ -209,9 +209,9 @@ Tags: {', '.join(memory.tags) if memory.tags else 'N/A'}
|
|
|
209
209
|
if preset_prompt:
|
|
210
210
|
parts.append(f"\nImage style guidance:\n{preset_prompt}")
|
|
211
211
|
|
|
212
|
-
# Add user's custom prompt
|
|
212
|
+
# Add user's custom prompt (escaped to prevent injection)
|
|
213
213
|
if request.custom_prompt:
|
|
214
|
-
parts.append(f"\nUser request: {request.custom_prompt}")
|
|
214
|
+
parts.append(f"\nUser request: {xml_escape(request.custom_prompt)}")
|
|
215
215
|
|
|
216
216
|
parts.append("\nGenerate a professional, high-quality image optimized for social media sharing.")
|
|
217
217
|
|
|
@@ -461,10 +461,10 @@ Tags: {', '.join(memory.tags) if memory.tags else 'N/A'}
|
|
|
461
461
|
"parts": parts
|
|
462
462
|
})
|
|
463
463
|
|
|
464
|
-
# Add refinement prompt
|
|
464
|
+
# Add refinement prompt (escaped to prevent injection)
|
|
465
465
|
contents.append({
|
|
466
466
|
"role": "user",
|
|
467
|
-
"parts": [{"text": refinement_prompt}]
|
|
467
|
+
"parts": [{"text": xml_escape(refinement_prompt)}]
|
|
468
468
|
})
|
|
469
469
|
|
|
470
470
|
# Configure - use defaults or provided values
|
|
@@ -135,6 +135,7 @@ class DatabaseChangeHandler(FileSystemEventHandler):
|
|
|
135
135
|
self.loop = loop
|
|
136
136
|
self._debounce_task: Optional[asyncio.Task] = None
|
|
137
137
|
self._last_path: Optional[str] = None
|
|
138
|
+
self._last_activity_count: dict[str, int] = {}
|
|
138
139
|
|
|
139
140
|
def on_modified(self, event):
|
|
140
141
|
if event.src_path.endswith("cortex.db") or event.src_path.endswith("global.db"):
|
|
@@ -146,9 +147,35 @@ class DatabaseChangeHandler(FileSystemEventHandler):
|
|
|
146
147
|
)
|
|
147
148
|
|
|
148
149
|
async def _debounced_notify(self):
|
|
149
|
-
await asyncio.sleep(0.
|
|
150
|
+
await asyncio.sleep(0.3) # Reduced from 0.5s for faster updates
|
|
150
151
|
if self._last_path:
|
|
151
|
-
|
|
152
|
+
db_path = self._last_path
|
|
153
|
+
|
|
154
|
+
# Broadcast general database change
|
|
155
|
+
await self.ws_manager.broadcast("database_changed", {"path": db_path})
|
|
156
|
+
|
|
157
|
+
# Fetch and broadcast latest activities (IndyDevDan pattern)
|
|
158
|
+
try:
|
|
159
|
+
# Get recent activities
|
|
160
|
+
recent = get_activities(db_path, limit=5, offset=0)
|
|
161
|
+
if recent:
|
|
162
|
+
# Broadcast each new activity
|
|
163
|
+
for activity in recent:
|
|
164
|
+
await self.ws_manager.broadcast_activity_logged(
|
|
165
|
+
db_path,
|
|
166
|
+
activity if isinstance(activity, dict) else activity.model_dump()
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Also broadcast session update
|
|
170
|
+
sessions = get_recent_sessions(db_path, limit=1)
|
|
171
|
+
if sessions:
|
|
172
|
+
session = sessions[0]
|
|
173
|
+
await self.ws_manager.broadcast_session_updated(
|
|
174
|
+
db_path,
|
|
175
|
+
session if isinstance(session, dict) else dict(session)
|
|
176
|
+
)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
print(f"[WS] Error broadcasting activities: {e}")
|
|
152
179
|
|
|
153
180
|
|
|
154
181
|
# File watcher
|
|
@@ -77,6 +77,28 @@ class WebSocketManager:
|
|
|
77
77
|
"""Get the number of active connections."""
|
|
78
78
|
return len(self.connections)
|
|
79
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
|
+
|
|
80
102
|
|
|
81
103
|
# Global manager instance
|
|
82
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()
|
|
@@ -22,10 +22,15 @@ import re
|
|
|
22
22
|
import sys
|
|
23
23
|
import os
|
|
24
24
|
import sqlite3
|
|
25
|
+
import time
|
|
25
26
|
from datetime import datetime, timezone
|
|
26
27
|
from pathlib import Path
|
|
28
|
+
from typing import Optional
|
|
27
29
|
|
|
28
30
|
|
|
31
|
+
# Session timeout in seconds (4 hours of inactivity = new session)
|
|
32
|
+
SESSION_TIMEOUT_SECONDS = 4 * 60 * 60
|
|
33
|
+
|
|
29
34
|
# Patterns for sensitive field names that should be redacted
|
|
30
35
|
SENSITIVE_FIELD_PATTERNS = [
|
|
31
36
|
r'(?i)(api[_-]?key|apikey)',
|
|
@@ -36,6 +41,128 @@ SENSITIVE_FIELD_PATTERNS = [
|
|
|
36
41
|
]
|
|
37
42
|
|
|
38
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
|
+
|
|
39
166
|
def redact_sensitive_fields(data: dict) -> dict:
|
|
40
167
|
"""Redact sensitive fields from a dictionary for safe logging.
|
|
41
168
|
|
|
@@ -157,13 +284,15 @@ def main():
|
|
|
157
284
|
print(json.dumps({}))
|
|
158
285
|
return
|
|
159
286
|
|
|
160
|
-
session_id = os.environ.get("CLAUDE_SESSION_ID")
|
|
161
287
|
project_path = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
|
|
162
288
|
|
|
163
289
|
# Auto-initialize database (creates if not exists)
|
|
164
290
|
db_path = get_db_path()
|
|
165
291
|
conn = ensure_database(db_path)
|
|
166
292
|
|
|
293
|
+
# Get or create session (auto-manages session lifecycle)
|
|
294
|
+
session_id = get_or_create_session(conn, project_path)
|
|
295
|
+
|
|
167
296
|
# Redact sensitive fields before logging
|
|
168
297
|
safe_input = redact_sensitive_fields(tool_input) if isinstance(tool_input, dict) else tool_input
|
|
169
298
|
|
|
@@ -22,7 +22,7 @@ from ..models.memory import (
|
|
|
22
22
|
from ..models.relationship import create_relationship, get_relationships, VALID_RELATIONSHIP_TYPES
|
|
23
23
|
from ..search.hybrid import search
|
|
24
24
|
from ..search.ranking import calculate_relevance_score
|
|
25
|
-
from ..utils.formatting import format_memory_markdown, format_memories_list_markdown
|
|
25
|
+
from ..utils.formatting import format_memory_markdown, format_memories_list_markdown, detect_injection_patterns
|
|
26
26
|
from ..embeddings import generate_and_store_embedding, is_model_available
|
|
27
27
|
from ..config import load_config
|
|
28
28
|
from ..database.sync import sync_memory_to_global, delete_memory_from_global
|
|
@@ -157,6 +157,14 @@ def register_memory_tools(mcp: FastMCP) -> None:
|
|
|
157
157
|
project_path = str(get_project_path())
|
|
158
158
|
session_id = get_session_id()
|
|
159
159
|
|
|
160
|
+
# Detect potential injection patterns in content
|
|
161
|
+
injection_warnings = detect_injection_patterns(params.content)
|
|
162
|
+
if injection_warnings:
|
|
163
|
+
import logging
|
|
164
|
+
logging.getLogger(__name__).warning(
|
|
165
|
+
f"Memory content contains potential injection patterns: {injection_warnings}"
|
|
166
|
+
)
|
|
167
|
+
|
|
160
168
|
# Create the memory
|
|
161
169
|
memory_data = MemoryCreate(
|
|
162
170
|
content=params.content,
|
|
@@ -218,13 +226,16 @@ def register_memory_tools(mcp: FastMCP) -> None:
|
|
|
218
226
|
)
|
|
219
227
|
|
|
220
228
|
embedding_status = "with embedding" if has_embedding else "no embedding"
|
|
221
|
-
|
|
229
|
+
result = (
|
|
222
230
|
f"Remembered: {memory.id}\n"
|
|
223
231
|
f"Type: {memory.type}\n"
|
|
224
232
|
f"Tags: {', '.join(memory.tags) if memory.tags else 'none'}\n"
|
|
225
233
|
f"Importance: {memory.importance_score:.0f}/100\n"
|
|
226
234
|
f"Search: {embedding_status}"
|
|
227
235
|
)
|
|
236
|
+
if injection_warnings:
|
|
237
|
+
result += f"\n[Security Note: Content contains patterns that may be injection attempts: {', '.join(injection_warnings)}]"
|
|
238
|
+
return result
|
|
228
239
|
|
|
229
240
|
except Exception as e:
|
|
230
241
|
return f"Error storing memory: {e}"
|
|
@@ -1,12 +1,45 @@
|
|
|
1
1
|
"""Output formatting utilities for Omni Cortex."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import re
|
|
5
|
+
from html import escape as html_escape
|
|
4
6
|
from typing import Any, Optional
|
|
5
7
|
from datetime import datetime
|
|
6
8
|
|
|
7
9
|
from .timestamps import format_relative_time
|
|
8
10
|
|
|
9
11
|
|
|
12
|
+
def xml_escape(text: str) -> str:
|
|
13
|
+
"""Escape text for safe inclusion in XML-structured outputs.
|
|
14
|
+
|
|
15
|
+
Prevents prompt injection by escaping special characters that
|
|
16
|
+
could be interpreted as XML/instruction delimiters.
|
|
17
|
+
"""
|
|
18
|
+
return html_escape(text, quote=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Known prompt injection patterns
|
|
22
|
+
_INJECTION_PATTERNS = [
|
|
23
|
+
(r'(?i)(ignore|disregard|forget)\s+(all\s+)?(previous|prior|above)\s+instructions?',
|
|
24
|
+
'instruction override'),
|
|
25
|
+
(r'(?i)(new\s+)?system\s+(prompt|instruction|message)',
|
|
26
|
+
'system prompt manipulation'),
|
|
27
|
+
(r'(?i)\[/?system\]|\[/?inst\]|<\/?system>|<\/?instruction>',
|
|
28
|
+
'fake delimiter'),
|
|
29
|
+
(r'(?i)bypass|jailbreak|DAN|GODMODE',
|
|
30
|
+
'jailbreak signature'),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def detect_injection_patterns(content: str) -> list[str]:
|
|
35
|
+
"""Detect potential prompt injection patterns in content."""
|
|
36
|
+
detected = []
|
|
37
|
+
for pattern, description in _INJECTION_PATTERNS:
|
|
38
|
+
if re.search(pattern, content):
|
|
39
|
+
detected.append(description)
|
|
40
|
+
return detected
|
|
41
|
+
|
|
42
|
+
|
|
10
43
|
def format_memory_markdown(
|
|
11
44
|
memory: dict[str, Any],
|
|
12
45
|
related_memories: Optional[list[dict[str, Any]]] = None,
|
|
@@ -27,9 +60,13 @@ def format_memory_markdown(
|
|
|
27
60
|
lines.append(f"## [{mem_type}] {memory.get('id', 'unknown')}")
|
|
28
61
|
lines.append("")
|
|
29
62
|
|
|
30
|
-
# Content
|
|
63
|
+
# Content - XML escape to prevent prompt injection when returned to Claude
|
|
31
64
|
content = memory.get("content", "")
|
|
32
|
-
|
|
65
|
+
# Detect and flag potential injection patterns
|
|
66
|
+
injections = detect_injection_patterns(content)
|
|
67
|
+
if injections:
|
|
68
|
+
lines.append(f"[Security Note: Content contains patterns that may be injection attempts: {', '.join(injections)}]")
|
|
69
|
+
lines.append(xml_escape(content))
|
|
33
70
|
lines.append("")
|
|
34
71
|
|
|
35
72
|
# Metadata
|
|
@@ -43,10 +80,10 @@ def format_memory_markdown(
|
|
|
43
80
|
if tags:
|
|
44
81
|
lines.append(f"**Tags:** {', '.join(tags)}")
|
|
45
82
|
|
|
46
|
-
# Context
|
|
83
|
+
# Context - also escape
|
|
47
84
|
context = memory.get("context")
|
|
48
85
|
if context:
|
|
49
|
-
lines.append(f"**Context:** {context}")
|
|
86
|
+
lines.append(f"**Context:** {xml_escape(context)}")
|
|
50
87
|
|
|
51
88
|
# Timestamps
|
|
52
89
|
created = memory.get("created_at")
|
|
@@ -72,7 +109,7 @@ def format_memory_markdown(
|
|
|
72
109
|
for related in related_memories[:3]: # Limit to 3
|
|
73
110
|
rel_type = related.get("relationship_type", "related_to")
|
|
74
111
|
rel_id = related.get("id", "unknown")
|
|
75
|
-
rel_content = related.get("content", "")[:50]
|
|
112
|
+
rel_content = xml_escape(related.get("content", "")[:50])
|
|
76
113
|
lines.append(f" - [{rel_type}] {rel_id}: {rel_content}...")
|
|
77
114
|
|
|
78
115
|
return "\n".join(lines)
|
|
@@ -189,7 +226,7 @@ def format_timeline_markdown(
|
|
|
189
226
|
else:
|
|
190
227
|
mem = item["data"]
|
|
191
228
|
lines.append(f"**Memory created**: [{mem.get('type')}] {mem.get('id')}")
|
|
192
|
-
content = mem.get("content", "")[:100]
|
|
229
|
+
content = xml_escape(mem.get("content", "")[:100])
|
|
193
230
|
lines.append(f" > {content}...")
|
|
194
231
|
lines.append("")
|
|
195
232
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "omni-cortex"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.6.0"
|
|
8
8
|
description = "Give Claude Code a perfect memory - auto-logs everything, searches smartly, and gets smarter over time"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -1,160 +0,0 @@
|
|
|
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 sys
|
|
22
|
-
import os
|
|
23
|
-
import sqlite3
|
|
24
|
-
from datetime import datetime, timezone
|
|
25
|
-
from pathlib import Path
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def get_db_path() -> Path:
|
|
29
|
-
"""Get the database path for the current project."""
|
|
30
|
-
project_path = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
|
|
31
|
-
return Path(project_path) / ".omni-cortex" / "cortex.db"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def ensure_database(db_path: Path) -> sqlite3.Connection:
|
|
35
|
-
"""Ensure database exists and is initialized.
|
|
36
|
-
|
|
37
|
-
Auto-creates the database and schema if it doesn't exist.
|
|
38
|
-
This enables 'out of the box' functionality.
|
|
39
|
-
"""
|
|
40
|
-
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
-
conn = sqlite3.connect(str(db_path))
|
|
42
|
-
|
|
43
|
-
# Check if schema exists
|
|
44
|
-
cursor = conn.cursor()
|
|
45
|
-
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='activities'")
|
|
46
|
-
if cursor.fetchone() is None:
|
|
47
|
-
# Apply minimal schema for activities (full schema applied by MCP)
|
|
48
|
-
conn.executescript("""
|
|
49
|
-
CREATE TABLE IF NOT EXISTS activities (
|
|
50
|
-
id TEXT PRIMARY KEY,
|
|
51
|
-
session_id TEXT,
|
|
52
|
-
agent_id TEXT,
|
|
53
|
-
timestamp TEXT NOT NULL,
|
|
54
|
-
event_type TEXT NOT NULL,
|
|
55
|
-
tool_name TEXT,
|
|
56
|
-
tool_input TEXT,
|
|
57
|
-
tool_output TEXT,
|
|
58
|
-
duration_ms INTEGER,
|
|
59
|
-
success INTEGER DEFAULT 1,
|
|
60
|
-
error_message TEXT,
|
|
61
|
-
project_path TEXT,
|
|
62
|
-
file_path TEXT,
|
|
63
|
-
metadata TEXT
|
|
64
|
-
);
|
|
65
|
-
CREATE INDEX IF NOT EXISTS idx_activities_timestamp ON activities(timestamp DESC);
|
|
66
|
-
CREATE INDEX IF NOT EXISTS idx_activities_tool ON activities(tool_name);
|
|
67
|
-
""")
|
|
68
|
-
conn.commit()
|
|
69
|
-
|
|
70
|
-
return conn
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def generate_id() -> str:
|
|
74
|
-
"""Generate a unique activity ID."""
|
|
75
|
-
timestamp_ms = int(datetime.now().timestamp() * 1000)
|
|
76
|
-
random_hex = os.urandom(4).hex()
|
|
77
|
-
return f"act_{timestamp_ms}_{random_hex}"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def truncate(text: str, max_length: int = 10000) -> str:
|
|
81
|
-
"""Truncate text to max length."""
|
|
82
|
-
if len(text) <= max_length:
|
|
83
|
-
return text
|
|
84
|
-
return text[:max_length - 20] + "\n... [truncated]"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def main():
|
|
88
|
-
"""Process PostToolUse hook."""
|
|
89
|
-
try:
|
|
90
|
-
# Read all input at once (more reliable than json.load on stdin)
|
|
91
|
-
raw_input = sys.stdin.read()
|
|
92
|
-
if not raw_input or not raw_input.strip():
|
|
93
|
-
print(json.dumps({}))
|
|
94
|
-
return
|
|
95
|
-
|
|
96
|
-
input_data = json.loads(raw_input)
|
|
97
|
-
|
|
98
|
-
# Extract data from hook input
|
|
99
|
-
tool_name = input_data.get("tool_name")
|
|
100
|
-
tool_input = input_data.get("tool_input", {})
|
|
101
|
-
tool_output = input_data.get("tool_output", {})
|
|
102
|
-
agent_id = input_data.get("agent_id")
|
|
103
|
-
|
|
104
|
-
# Determine success/error
|
|
105
|
-
is_error = input_data.get("is_error", False)
|
|
106
|
-
error_message = None
|
|
107
|
-
if is_error and isinstance(tool_output, dict):
|
|
108
|
-
error_message = tool_output.get("error") or tool_output.get("message")
|
|
109
|
-
|
|
110
|
-
# Skip logging our own tools to prevent recursion
|
|
111
|
-
# MCP tools are named like "mcp__omni-cortex__cortex_remember"
|
|
112
|
-
if tool_name and ("cortex_" in tool_name or "omni-cortex" in tool_name):
|
|
113
|
-
print(json.dumps({}))
|
|
114
|
-
return
|
|
115
|
-
|
|
116
|
-
session_id = os.environ.get("CLAUDE_SESSION_ID")
|
|
117
|
-
project_path = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
|
|
118
|
-
|
|
119
|
-
# Auto-initialize database (creates if not exists)
|
|
120
|
-
db_path = get_db_path()
|
|
121
|
-
conn = ensure_database(db_path)
|
|
122
|
-
|
|
123
|
-
# Insert activity record
|
|
124
|
-
cursor = conn.cursor()
|
|
125
|
-
cursor.execute(
|
|
126
|
-
"""
|
|
127
|
-
INSERT INTO activities (
|
|
128
|
-
id, session_id, agent_id, timestamp, event_type,
|
|
129
|
-
tool_name, tool_input, tool_output, success, error_message, project_path
|
|
130
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
131
|
-
""",
|
|
132
|
-
(
|
|
133
|
-
generate_id(),
|
|
134
|
-
session_id,
|
|
135
|
-
agent_id,
|
|
136
|
-
datetime.now(timezone.utc).isoformat(),
|
|
137
|
-
"post_tool_use",
|
|
138
|
-
tool_name,
|
|
139
|
-
truncate(json.dumps(tool_input, default=str)),
|
|
140
|
-
truncate(json.dumps(tool_output, default=str)),
|
|
141
|
-
0 if is_error else 1,
|
|
142
|
-
error_message,
|
|
143
|
-
project_path,
|
|
144
|
-
),
|
|
145
|
-
)
|
|
146
|
-
conn.commit()
|
|
147
|
-
conn.close()
|
|
148
|
-
|
|
149
|
-
# Return empty response (no modification)
|
|
150
|
-
print(json.dumps({}))
|
|
151
|
-
|
|
152
|
-
except Exception as e:
|
|
153
|
-
# Hooks should never block - log error but continue
|
|
154
|
-
print(json.dumps({"systemMessage": f"Cortex post_tool_use: {e}"}))
|
|
155
|
-
|
|
156
|
-
sys.exit(0)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if __name__ == "__main__":
|
|
160
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|