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.
Files changed (68) hide show
  1. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/PKG-INFO +1 -1
  2. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/chat_service.py +5 -1
  3. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/image_service.py +4 -4
  4. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/main.py +29 -2
  5. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/websocket_manager.py +22 -0
  6. omni_cortex-1.6.0/hooks/post_tool_use.py +335 -0
  7. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/hooks/pre_tool_use.py +130 -1
  8. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/__init__.py +1 -1
  9. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/tools/memories.py +13 -2
  10. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/utils/formatting.py +43 -6
  11. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/pyproject.toml +1 -1
  12. omni_cortex-1.5.0/hooks/post_tool_use.py +0 -160
  13. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/.gitignore +0 -0
  14. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/LICENSE +0 -0
  15. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/README.md +0 -0
  16. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/.env.example +0 -0
  17. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/backfill_summaries.py +0 -0
  18. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/database.py +0 -0
  19. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/logging_config.py +0 -0
  20. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/models.py +0 -0
  21. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/project_config.py +0 -0
  22. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/project_scanner.py +0 -0
  23. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/prompt_security.py +0 -0
  24. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/pyproject.toml +0 -0
  25. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/security.py +0 -0
  26. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/dashboard/backend/uv.lock +0 -0
  27. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/hooks/stop.py +0 -0
  28. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/hooks/subagent_stop.py +0 -0
  29. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/categorization/__init__.py +0 -0
  30. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/categorization/auto_tags.py +0 -0
  31. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/categorization/auto_type.py +0 -0
  32. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/config.py +0 -0
  33. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/dashboard.py +0 -0
  34. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/database/__init__.py +0 -0
  35. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/database/connection.py +0 -0
  36. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/database/migrations.py +0 -0
  37. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/database/schema.py +0 -0
  38. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/database/sync.py +0 -0
  39. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/decay/__init__.py +0 -0
  40. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/decay/importance.py +0 -0
  41. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/embeddings/__init__.py +0 -0
  42. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/embeddings/local.py +0 -0
  43. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/models/__init__.py +0 -0
  44. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/models/activity.py +0 -0
  45. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/models/agent.py +0 -0
  46. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/models/memory.py +0 -0
  47. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/models/relationship.py +0 -0
  48. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/models/session.py +0 -0
  49. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/resources/__init__.py +0 -0
  50. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/search/__init__.py +0 -0
  51. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/search/hybrid.py +0 -0
  52. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/search/keyword.py +0 -0
  53. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/search/ranking.py +0 -0
  54. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/search/semantic.py +0 -0
  55. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/server.py +0 -0
  56. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/setup.py +0 -0
  57. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/tools/__init__.py +0 -0
  58. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/tools/activities.py +0 -0
  59. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/tools/sessions.py +0 -0
  60. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/tools/utilities.py +0 -0
  61. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/utils/__init__.py +0 -0
  62. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/utils/ids.py +0 -0
  63. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/utils/timestamps.py +0 -0
  64. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/omni_cortex/utils/truncation.py +0 -0
  65. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/scripts/check-venv.py +0 -0
  66. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/scripts/import_ken_memories.py +0 -0
  67. {omni_cortex-1.5.0 → omni_cortex-1.6.0}/scripts/populate_session_data.py +0 -0
  68. {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.5.0
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
- {content[:2000]}
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.5) # Wait for rapid changes to settle
150
+ await asyncio.sleep(0.3) # Reduced from 0.5s for faster updates
150
151
  if self._last_path:
151
- await self.ws_manager.broadcast("database_changed", {"path": self._last_path})
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
 
@@ -1,3 +1,3 @@
1
1
  """Omni Cortex MCP - Universal Memory System for Claude Code."""
2
2
 
3
- __version__ = "1.5.0"
3
+ __version__ = "1.6.0"
@@ -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
- return (
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
- lines.append(content)
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.5.0"
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