superlocalmemory 2.4.2 → 2.5.1
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.
- package/CHANGELOG.md +62 -0
- package/README.md +62 -2
- package/docs/ARCHITECTURE-V2.5.md +190 -0
- package/docs/architecture-diagram.drawio +405 -0
- package/mcp_server.py +115 -14
- package/package.json +4 -1
- package/scripts/generate-thumbnails.py +220 -0
- package/src/agent_registry.py +385 -0
- package/src/db_connection_manager.py +532 -0
- package/src/event_bus.py +555 -0
- package/src/memory_store_v2.py +626 -471
- package/src/provenance_tracker.py +322 -0
- package/src/subscription_manager.py +399 -0
- package/src/trust_scorer.py +456 -0
- package/src/webhook_dispatcher.py +229 -0
- package/ui/app.js +425 -0
- package/ui/index.html +147 -1
- package/ui/js/agents.js +192 -0
- package/ui/js/clusters.js +80 -0
- package/ui/js/core.js +230 -0
- package/ui/js/events.js +178 -0
- package/ui/js/graph.js +32 -0
- package/ui/js/init.js +31 -0
- package/ui/js/memories.js +149 -0
- package/ui/js/modal.js +139 -0
- package/ui/js/patterns.js +93 -0
- package/ui/js/profiles.js +202 -0
- package/ui/js/search.js +59 -0
- package/ui/js/settings.js +167 -0
- package/ui/js/timeline.js +32 -0
- package/ui_server.py +69 -1665
- package/docs/COMPETITIVE-ANALYSIS.md +0 -210
|
@@ -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
|