superlocalmemory 2.4.1 → 2.5.0

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.
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SuperLocalMemory V2 - Subscription Manager
4
+ Copyright (c) 2026 Varun Pratap Bhardwaj
5
+ Licensed under MIT License
6
+
7
+ Repository: https://github.com/varun369/SuperLocalMemoryV2
8
+ Author: Varun Pratap Bhardwaj (Solution Architect)
9
+
10
+ NOTICE: This software is protected by MIT License.
11
+ Attribution must be preserved in all copies or derivatives.
12
+ """
13
+
14
+ """
15
+ SubscriptionManager — Manages durable and ephemeral event subscriptions.
16
+
17
+ Subscribers register interest in specific event types and receive matching
18
+ events via their chosen channel (SSE, WebSocket, Webhook).
19
+
20
+ Subscription Types:
21
+ Durable (default) — Persisted to DB, survives disconnect, auto-replay on reconnect
22
+ Ephemeral (opt-in) — In-memory only, dies on disconnect
23
+
24
+ Filter Syntax:
25
+ {
26
+ "event_types": ["memory.created", "memory.deleted"], // null = all types
27
+ "min_importance": 5, // null = no filter
28
+ "source_protocols": ["mcp", "cli"], // null = all protocols
29
+ "projects": ["myapp"] // null = all projects
30
+ }
31
+ """
32
+
33
+ import json
34
+ import logging
35
+ import threading
36
+ from datetime import datetime
37
+ from pathlib import Path
38
+ from typing import Optional, List, Dict, Any
39
+
40
+ logger = logging.getLogger("superlocalmemory.subscriptions")
41
+
42
+
43
+ class SubscriptionManager:
44
+ """
45
+ Manages event subscriptions for the Event Bus.
46
+
47
+ Thread-safe. Durable subscriptions persist to SQLite. Ephemeral
48
+ subscriptions are in-memory only.
49
+ """
50
+
51
+ _instances: Dict[str, "SubscriptionManager"] = {}
52
+ _instances_lock = threading.Lock()
53
+
54
+ @classmethod
55
+ def get_instance(cls, db_path: Optional[Path] = None) -> "SubscriptionManager":
56
+ """Get or create the singleton SubscriptionManager."""
57
+ if db_path is None:
58
+ db_path = Path.home() / ".claude-memory" / "memory.db"
59
+ key = str(db_path)
60
+ with cls._instances_lock:
61
+ if key not in cls._instances:
62
+ cls._instances[key] = cls(db_path)
63
+ return cls._instances[key]
64
+
65
+ @classmethod
66
+ def reset_instance(cls, db_path: Optional[Path] = None):
67
+ """Remove singleton. Used for testing."""
68
+ with cls._instances_lock:
69
+ if db_path is None:
70
+ cls._instances.clear()
71
+ else:
72
+ key = str(db_path)
73
+ if key in cls._instances:
74
+ del cls._instances[key]
75
+
76
+ def __init__(self, db_path: Path):
77
+ self.db_path = Path(db_path)
78
+
79
+ # Ephemeral subscriptions (in-memory only)
80
+ self._ephemeral: Dict[str, dict] = {}
81
+ self._ephemeral_lock = threading.Lock()
82
+
83
+ self._init_schema()
84
+ logger.info("SubscriptionManager initialized: db=%s", self.db_path)
85
+
86
+ def _init_schema(self):
87
+ """Create subscriptions table if it doesn't exist."""
88
+ try:
89
+ from db_connection_manager import DbConnectionManager
90
+ mgr = DbConnectionManager.get_instance(self.db_path)
91
+
92
+ def _create(conn):
93
+ conn.execute('''
94
+ CREATE TABLE IF NOT EXISTS subscriptions (
95
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
96
+ subscriber_id TEXT NOT NULL UNIQUE,
97
+ channel TEXT NOT NULL,
98
+ filter TEXT NOT NULL DEFAULT '{}',
99
+ webhook_url TEXT,
100
+ durable INTEGER DEFAULT 1,
101
+ last_event_id INTEGER DEFAULT 0,
102
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
103
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
104
+ )
105
+ ''')
106
+ conn.execute('''
107
+ CREATE INDEX IF NOT EXISTS idx_subs_channel
108
+ ON subscriptions(channel)
109
+ ''')
110
+ conn.commit()
111
+
112
+ mgr.execute_write(_create)
113
+ except ImportError:
114
+ import sqlite3
115
+ conn = sqlite3.connect(str(self.db_path))
116
+ conn.execute('''
117
+ CREATE TABLE IF NOT EXISTS subscriptions (
118
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
119
+ subscriber_id TEXT NOT NULL UNIQUE,
120
+ channel TEXT NOT NULL,
121
+ filter TEXT NOT NULL DEFAULT '{}',
122
+ webhook_url TEXT,
123
+ durable INTEGER DEFAULT 1,
124
+ last_event_id INTEGER DEFAULT 0,
125
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
126
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
127
+ )
128
+ ''')
129
+ conn.execute('CREATE INDEX IF NOT EXISTS idx_subs_channel ON subscriptions(channel)')
130
+ conn.commit()
131
+ conn.close()
132
+
133
+ # =========================================================================
134
+ # Subscribe / Unsubscribe
135
+ # =========================================================================
136
+
137
+ def subscribe(
138
+ self,
139
+ subscriber_id: str,
140
+ channel: str = "sse",
141
+ filter_obj: Optional[dict] = None,
142
+ webhook_url: Optional[str] = None,
143
+ durable: bool = True,
144
+ ) -> dict:
145
+ """
146
+ Register a subscription.
147
+
148
+ Args:
149
+ subscriber_id: Unique identifier for the subscriber
150
+ channel: Delivery channel — 'sse', 'websocket', 'webhook'
151
+ filter_obj: Event filter (see module docstring for syntax)
152
+ webhook_url: URL for webhook channel (required if channel='webhook')
153
+ durable: If True, persists to DB; if False, in-memory only
154
+
155
+ Returns:
156
+ Subscription dict with id and details
157
+
158
+ Raises:
159
+ ValueError: If channel is invalid or webhook_url missing for webhook channel
160
+ """
161
+ if channel not in ("sse", "websocket", "webhook"):
162
+ raise ValueError(f"Invalid channel: {channel}. Must be sse, websocket, or webhook")
163
+
164
+ if channel == "webhook" and not webhook_url:
165
+ raise ValueError("webhook_url is required for webhook channel")
166
+
167
+ # Validate webhook URL format
168
+ if webhook_url and not (webhook_url.startswith("http://") or webhook_url.startswith("https://")):
169
+ raise ValueError("webhook_url must start with http:// or https://")
170
+
171
+ filter_json = json.dumps(filter_obj or {})
172
+ now = datetime.now().isoformat()
173
+
174
+ sub = {
175
+ "subscriber_id": subscriber_id,
176
+ "channel": channel,
177
+ "filter": filter_obj or {},
178
+ "webhook_url": webhook_url,
179
+ "durable": durable,
180
+ "last_event_id": 0,
181
+ "created_at": now,
182
+ }
183
+
184
+ if durable:
185
+ self._persist_subscription(sub, filter_json)
186
+ else:
187
+ with self._ephemeral_lock:
188
+ self._ephemeral[subscriber_id] = sub
189
+
190
+ logger.info("Subscription created: id=%s, channel=%s, durable=%s", subscriber_id, channel, durable)
191
+ return sub
192
+
193
+ def _persist_subscription(self, sub: dict, filter_json: str):
194
+ """Save durable subscription to database."""
195
+ try:
196
+ from db_connection_manager import DbConnectionManager
197
+ mgr = DbConnectionManager.get_instance(self.db_path)
198
+
199
+ def _upsert(conn):
200
+ conn.execute('''
201
+ INSERT INTO subscriptions (subscriber_id, channel, filter, webhook_url, durable, created_at, updated_at)
202
+ VALUES (?, ?, ?, ?, 1, ?, ?)
203
+ ON CONFLICT(subscriber_id) DO UPDATE SET
204
+ channel = excluded.channel,
205
+ filter = excluded.filter,
206
+ webhook_url = excluded.webhook_url,
207
+ updated_at = excluded.updated_at
208
+ ''', (
209
+ sub["subscriber_id"],
210
+ sub["channel"],
211
+ filter_json,
212
+ sub.get("webhook_url"),
213
+ sub["created_at"],
214
+ sub["created_at"],
215
+ ))
216
+ conn.commit()
217
+
218
+ mgr.execute_write(_upsert)
219
+ except Exception as e:
220
+ logger.error("Failed to persist subscription: %s", e)
221
+
222
+ def unsubscribe(self, subscriber_id: str) -> bool:
223
+ """
224
+ Remove a subscription (durable or ephemeral).
225
+
226
+ Args:
227
+ subscriber_id: ID of the subscription to remove
228
+
229
+ Returns:
230
+ True if subscription was found and removed
231
+ """
232
+ removed = False
233
+
234
+ # Remove ephemeral
235
+ with self._ephemeral_lock:
236
+ if subscriber_id in self._ephemeral:
237
+ del self._ephemeral[subscriber_id]
238
+ removed = True
239
+
240
+ # Remove durable
241
+ try:
242
+ from db_connection_manager import DbConnectionManager
243
+ mgr = DbConnectionManager.get_instance(self.db_path)
244
+
245
+ def _delete(conn):
246
+ conn.execute("DELETE FROM subscriptions WHERE subscriber_id = ?", (subscriber_id,))
247
+ conn.commit()
248
+ return conn.total_changes > 0
249
+
250
+ if mgr.execute_write(_delete):
251
+ removed = True
252
+ except Exception as e:
253
+ logger.error("Failed to delete subscription: %s", e)
254
+
255
+ return removed
256
+
257
+ def update_last_event_id(self, subscriber_id: str, event_id: int):
258
+ """Update the last event ID received by a durable subscriber (for replay)."""
259
+ try:
260
+ from db_connection_manager import DbConnectionManager
261
+ mgr = DbConnectionManager.get_instance(self.db_path)
262
+
263
+ def _update(conn):
264
+ conn.execute(
265
+ "UPDATE subscriptions SET last_event_id = ?, updated_at = ? WHERE subscriber_id = ?",
266
+ (event_id, datetime.now().isoformat(), subscriber_id)
267
+ )
268
+ conn.commit()
269
+
270
+ mgr.execute_write(_update)
271
+ except Exception as e:
272
+ logger.error("Failed to update last_event_id: %s", e)
273
+
274
+ # =========================================================================
275
+ # Query Subscriptions
276
+ # =========================================================================
277
+
278
+ def get_matching_subscribers(self, event: dict) -> List[dict]:
279
+ """
280
+ Get all subscriptions that match a given event.
281
+
282
+ Applies filter logic: event_types, min_importance, source_protocols.
283
+
284
+ Args:
285
+ event: Event dict with event_type, importance, source_protocol, etc.
286
+
287
+ Returns:
288
+ List of matching subscription dicts
289
+ """
290
+ all_subs = self.list_subscriptions()
291
+ matching = []
292
+
293
+ for sub in all_subs:
294
+ if self._matches_filter(sub.get("filter", {}), event):
295
+ matching.append(sub)
296
+
297
+ return matching
298
+
299
+ def _matches_filter(self, filter_obj: dict, event: dict) -> bool:
300
+ """Check if an event matches a subscription filter."""
301
+ if not filter_obj:
302
+ return True # No filter = match all
303
+
304
+ # Event type filter
305
+ allowed_types = filter_obj.get("event_types")
306
+ if allowed_types and event.get("event_type") not in allowed_types:
307
+ return False
308
+
309
+ # Importance filter
310
+ min_importance = filter_obj.get("min_importance")
311
+ if min_importance and (event.get("importance", 0) < min_importance):
312
+ return False
313
+
314
+ # Protocol filter
315
+ allowed_protocols = filter_obj.get("source_protocols")
316
+ if allowed_protocols and event.get("source_protocol") not in allowed_protocols:
317
+ return False
318
+
319
+ return True
320
+
321
+ def list_subscriptions(self) -> List[dict]:
322
+ """Get all active subscriptions (durable + ephemeral)."""
323
+ subs = []
324
+
325
+ # Ephemeral
326
+ with self._ephemeral_lock:
327
+ subs.extend(list(self._ephemeral.values()))
328
+
329
+ # Durable (from DB)
330
+ try:
331
+ from db_connection_manager import DbConnectionManager
332
+ mgr = DbConnectionManager.get_instance(self.db_path)
333
+
334
+ with mgr.read_connection() as conn:
335
+ cursor = conn.cursor()
336
+ cursor.execute("""
337
+ SELECT subscriber_id, channel, filter, webhook_url, durable,
338
+ last_event_id, created_at, updated_at
339
+ FROM subscriptions
340
+ """)
341
+ for row in cursor.fetchall():
342
+ filter_obj = {}
343
+ try:
344
+ filter_obj = json.loads(row[2]) if row[2] else {}
345
+ except (json.JSONDecodeError, TypeError):
346
+ pass
347
+
348
+ subs.append({
349
+ "subscriber_id": row[0],
350
+ "channel": row[1],
351
+ "filter": filter_obj,
352
+ "webhook_url": row[3],
353
+ "durable": bool(row[4]),
354
+ "last_event_id": row[5],
355
+ "created_at": row[6],
356
+ "updated_at": row[7],
357
+ })
358
+ except Exception as e:
359
+ logger.error("Failed to list durable subscriptions: %s", e)
360
+
361
+ return subs
362
+
363
+ def get_subscription(self, subscriber_id: str) -> Optional[dict]:
364
+ """Get a specific subscription by ID."""
365
+ # Check ephemeral first
366
+ with self._ephemeral_lock:
367
+ if subscriber_id in self._ephemeral:
368
+ return self._ephemeral[subscriber_id]
369
+
370
+ # Check durable
371
+ try:
372
+ from db_connection_manager import DbConnectionManager
373
+ mgr = DbConnectionManager.get_instance(self.db_path)
374
+
375
+ with mgr.read_connection() as conn:
376
+ cursor = conn.cursor()
377
+ cursor.execute(
378
+ "SELECT subscriber_id, channel, filter, webhook_url, durable, last_event_id FROM subscriptions WHERE subscriber_id = ?",
379
+ (subscriber_id,)
380
+ )
381
+ row = cursor.fetchone()
382
+ if row:
383
+ filter_obj = {}
384
+ try:
385
+ filter_obj = json.loads(row[2]) if row[2] else {}
386
+ except (json.JSONDecodeError, TypeError):
387
+ pass
388
+ return {
389
+ "subscriber_id": row[0],
390
+ "channel": row[1],
391
+ "filter": filter_obj,
392
+ "webhook_url": row[3],
393
+ "durable": bool(row[4]),
394
+ "last_event_id": row[5],
395
+ }
396
+ except Exception as e:
397
+ logger.error("Failed to get subscription: %s", e)
398
+
399
+ return None