neural-memory 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- neural_memory/__init__.py +38 -0
- neural_memory/cli/__init__.py +15 -0
- neural_memory/cli/__main__.py +6 -0
- neural_memory/cli/config.py +176 -0
- neural_memory/cli/main.py +2702 -0
- neural_memory/cli/storage.py +169 -0
- neural_memory/cli/tui.py +471 -0
- neural_memory/core/__init__.py +52 -0
- neural_memory/core/brain.py +301 -0
- neural_memory/core/brain_mode.py +273 -0
- neural_memory/core/fiber.py +236 -0
- neural_memory/core/memory_types.py +331 -0
- neural_memory/core/neuron.py +168 -0
- neural_memory/core/project.py +257 -0
- neural_memory/core/synapse.py +215 -0
- neural_memory/engine/__init__.py +15 -0
- neural_memory/engine/activation.py +335 -0
- neural_memory/engine/encoder.py +391 -0
- neural_memory/engine/retrieval.py +440 -0
- neural_memory/extraction/__init__.py +42 -0
- neural_memory/extraction/entities.py +547 -0
- neural_memory/extraction/parser.py +337 -0
- neural_memory/extraction/router.py +396 -0
- neural_memory/extraction/temporal.py +428 -0
- neural_memory/mcp/__init__.py +9 -0
- neural_memory/mcp/__main__.py +6 -0
- neural_memory/mcp/server.py +621 -0
- neural_memory/py.typed +0 -0
- neural_memory/safety/__init__.py +31 -0
- neural_memory/safety/freshness.py +238 -0
- neural_memory/safety/sensitive.py +304 -0
- neural_memory/server/__init__.py +5 -0
- neural_memory/server/app.py +99 -0
- neural_memory/server/dependencies.py +33 -0
- neural_memory/server/models.py +138 -0
- neural_memory/server/routes/__init__.py +7 -0
- neural_memory/server/routes/brain.py +221 -0
- neural_memory/server/routes/memory.py +169 -0
- neural_memory/server/routes/sync.py +387 -0
- neural_memory/storage/__init__.py +17 -0
- neural_memory/storage/base.py +441 -0
- neural_memory/storage/factory.py +329 -0
- neural_memory/storage/memory_store.py +896 -0
- neural_memory/storage/shared_store.py +650 -0
- neural_memory/storage/sqlite_store.py +1613 -0
- neural_memory/sync/__init__.py +5 -0
- neural_memory/sync/client.py +435 -0
- neural_memory/unified_config.py +315 -0
- neural_memory/utils/__init__.py +5 -0
- neural_memory/utils/config.py +98 -0
- neural_memory-0.1.0.dist-info/METADATA +314 -0
- neural_memory-0.1.0.dist-info/RECORD +55 -0
- neural_memory-0.1.0.dist-info/WHEEL +4 -0
- neural_memory-0.1.0.dist-info/entry_points.txt +4 -0
- neural_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""WebSocket client for real-time brain synchronization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import aiohttp
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SyncClientState(StrEnum):
|
|
18
|
+
"""Client connection state."""
|
|
19
|
+
|
|
20
|
+
DISCONNECTED = "disconnected"
|
|
21
|
+
CONNECTING = "connecting"
|
|
22
|
+
CONNECTED = "connected"
|
|
23
|
+
RECONNECTING = "reconnecting"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SyncEvent:
|
|
28
|
+
"""A synchronization event received from server."""
|
|
29
|
+
|
|
30
|
+
type: str
|
|
31
|
+
brain_id: str
|
|
32
|
+
timestamp: datetime
|
|
33
|
+
data: dict[str, Any]
|
|
34
|
+
source_client_id: str | None = None
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_dict(cls, data: dict[str, Any]) -> SyncEvent:
|
|
38
|
+
"""Create from dictionary."""
|
|
39
|
+
return cls(
|
|
40
|
+
type=data["type"],
|
|
41
|
+
brain_id=data["brain_id"],
|
|
42
|
+
timestamp=datetime.fromisoformat(data["timestamp"])
|
|
43
|
+
if data.get("timestamp")
|
|
44
|
+
else datetime.utcnow(),
|
|
45
|
+
data=data.get("data", {}),
|
|
46
|
+
source_client_id=data.get("source_client_id"),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
EventHandler = Callable[[SyncEvent], None]
|
|
51
|
+
AsyncEventHandler = Callable[[SyncEvent], Any]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SyncClient:
|
|
55
|
+
"""
|
|
56
|
+
WebSocket client for real-time brain synchronization.
|
|
57
|
+
|
|
58
|
+
Enables receiving real-time updates when other agents modify
|
|
59
|
+
shared brains, and pushing local changes to the server.
|
|
60
|
+
|
|
61
|
+
Usage:
|
|
62
|
+
async with SyncClient("ws://localhost:8000") as client:
|
|
63
|
+
# Subscribe to brain updates
|
|
64
|
+
await client.subscribe("brain-123")
|
|
65
|
+
|
|
66
|
+
# Register event handlers
|
|
67
|
+
client.on("neuron_created", handle_neuron_created)
|
|
68
|
+
client.on("memory_encoded", handle_memory_encoded)
|
|
69
|
+
|
|
70
|
+
# Push local changes
|
|
71
|
+
await client.push_event("neuron_created", "brain-123", {"id": "..."})
|
|
72
|
+
|
|
73
|
+
# Keep running
|
|
74
|
+
await client.run_forever()
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
server_url: str,
|
|
80
|
+
*,
|
|
81
|
+
client_id: str | None = None,
|
|
82
|
+
auto_reconnect: bool = True,
|
|
83
|
+
reconnect_delay: float = 1.0,
|
|
84
|
+
max_reconnect_attempts: int = 10,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Initialize sync client.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
server_url: WebSocket URL (e.g., "ws://localhost:8000/sync/ws")
|
|
91
|
+
client_id: Optional client identifier (auto-generated if not provided)
|
|
92
|
+
auto_reconnect: Whether to auto-reconnect on disconnect
|
|
93
|
+
reconnect_delay: Base delay between reconnect attempts (exponential backoff)
|
|
94
|
+
max_reconnect_attempts: Maximum reconnect attempts (0 = unlimited)
|
|
95
|
+
"""
|
|
96
|
+
# Normalize WebSocket URL
|
|
97
|
+
if server_url.startswith("http://"):
|
|
98
|
+
server_url = server_url.replace("http://", "ws://")
|
|
99
|
+
elif server_url.startswith("https://"):
|
|
100
|
+
server_url = server_url.replace("https://", "wss://")
|
|
101
|
+
|
|
102
|
+
if not server_url.endswith("/sync/ws"):
|
|
103
|
+
server_url = server_url.rstrip("/") + "/sync/ws"
|
|
104
|
+
|
|
105
|
+
self._server_url = server_url
|
|
106
|
+
self._client_id = client_id or f"client-{uuid.uuid4().hex[:8]}"
|
|
107
|
+
self._auto_reconnect = auto_reconnect
|
|
108
|
+
self._reconnect_delay = reconnect_delay
|
|
109
|
+
self._max_reconnect_attempts = max_reconnect_attempts
|
|
110
|
+
|
|
111
|
+
self._session: aiohttp.ClientSession | None = None
|
|
112
|
+
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
|
113
|
+
self._state = SyncClientState.DISCONNECTED
|
|
114
|
+
self._subscribed_brains: set[str] = set()
|
|
115
|
+
self._handlers: dict[str, list[AsyncEventHandler]] = {}
|
|
116
|
+
self._reconnect_attempts = 0
|
|
117
|
+
self._running = False
|
|
118
|
+
self._receive_task: asyncio.Task[None] | None = None
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def client_id(self) -> str:
|
|
122
|
+
"""Get client ID."""
|
|
123
|
+
return self._client_id
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def state(self) -> SyncClientState:
|
|
127
|
+
"""Get current connection state."""
|
|
128
|
+
return self._state
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def is_connected(self) -> bool:
|
|
132
|
+
"""Check if connected to server."""
|
|
133
|
+
return self._state == SyncClientState.CONNECTED
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def subscribed_brains(self) -> frozenset[str]:
|
|
137
|
+
"""Get set of subscribed brain IDs."""
|
|
138
|
+
return frozenset(self._subscribed_brains)
|
|
139
|
+
|
|
140
|
+
async def connect(self) -> None:
|
|
141
|
+
"""Connect to the sync server."""
|
|
142
|
+
if self._state == SyncClientState.CONNECTED:
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
self._state = SyncClientState.CONNECTING
|
|
146
|
+
|
|
147
|
+
if self._session is None:
|
|
148
|
+
self._session = aiohttp.ClientSession()
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
self._ws = await self._session.ws_connect(self._server_url)
|
|
152
|
+
|
|
153
|
+
# Send connect message
|
|
154
|
+
await self._send({"action": "connect", "client_id": self._client_id})
|
|
155
|
+
|
|
156
|
+
# Wait for connected confirmation
|
|
157
|
+
response = await self._ws.receive()
|
|
158
|
+
if response.type == aiohttp.WSMsgType.TEXT:
|
|
159
|
+
data = json.loads(response.data)
|
|
160
|
+
if data.get("type") == "connected":
|
|
161
|
+
self._state = SyncClientState.CONNECTED
|
|
162
|
+
self._reconnect_attempts = 0
|
|
163
|
+
|
|
164
|
+
# Re-subscribe to brains
|
|
165
|
+
for brain_id in self._subscribed_brains:
|
|
166
|
+
await self._send({"action": "subscribe", "brain_id": brain_id})
|
|
167
|
+
|
|
168
|
+
except Exception as e:
|
|
169
|
+
self._state = SyncClientState.DISCONNECTED
|
|
170
|
+
raise ConnectionError(f"Failed to connect to sync server: {e}") from e
|
|
171
|
+
|
|
172
|
+
async def disconnect(self) -> None:
|
|
173
|
+
"""Disconnect from the sync server."""
|
|
174
|
+
self._running = False
|
|
175
|
+
|
|
176
|
+
if self._receive_task:
|
|
177
|
+
self._receive_task.cancel()
|
|
178
|
+
try:
|
|
179
|
+
await self._receive_task
|
|
180
|
+
except asyncio.CancelledError:
|
|
181
|
+
pass
|
|
182
|
+
self._receive_task = None
|
|
183
|
+
|
|
184
|
+
if self._ws:
|
|
185
|
+
await self._ws.close()
|
|
186
|
+
self._ws = None
|
|
187
|
+
|
|
188
|
+
if self._session:
|
|
189
|
+
await self._session.close()
|
|
190
|
+
self._session = None
|
|
191
|
+
|
|
192
|
+
self._state = SyncClientState.DISCONNECTED
|
|
193
|
+
|
|
194
|
+
async def __aenter__(self) -> SyncClient:
|
|
195
|
+
"""Async context manager entry."""
|
|
196
|
+
await self.connect()
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
async def __aexit__(
|
|
200
|
+
self,
|
|
201
|
+
exc_type: type[BaseException] | None,
|
|
202
|
+
exc_val: BaseException | None,
|
|
203
|
+
exc_tb: Any,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Async context manager exit."""
|
|
206
|
+
await self.disconnect()
|
|
207
|
+
|
|
208
|
+
async def subscribe(self, brain_id: str) -> bool:
|
|
209
|
+
"""
|
|
210
|
+
Subscribe to updates for a brain.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
brain_id: The brain ID to subscribe to
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
True if subscription succeeded
|
|
217
|
+
"""
|
|
218
|
+
self._subscribed_brains.add(brain_id)
|
|
219
|
+
|
|
220
|
+
if not self.is_connected:
|
|
221
|
+
return True # Will subscribe when connected
|
|
222
|
+
|
|
223
|
+
await self._send({"action": "subscribe", "brain_id": brain_id})
|
|
224
|
+
|
|
225
|
+
# Wait for confirmation
|
|
226
|
+
if self._ws:
|
|
227
|
+
response = await self._ws.receive()
|
|
228
|
+
if response.type == aiohttp.WSMsgType.TEXT:
|
|
229
|
+
data = json.loads(response.data)
|
|
230
|
+
return data.get("type") == "subscribed"
|
|
231
|
+
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
async def unsubscribe(self, brain_id: str) -> bool:
|
|
235
|
+
"""
|
|
236
|
+
Unsubscribe from updates for a brain.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
brain_id: The brain ID to unsubscribe from
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
True if unsubscription succeeded
|
|
243
|
+
"""
|
|
244
|
+
self._subscribed_brains.discard(brain_id)
|
|
245
|
+
|
|
246
|
+
if not self.is_connected:
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
await self._send({"action": "unsubscribe", "brain_id": brain_id})
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
def on(self, event_type: str, handler: AsyncEventHandler) -> None:
|
|
253
|
+
"""
|
|
254
|
+
Register an event handler.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
event_type: The event type to handle (e.g., "neuron_created")
|
|
258
|
+
handler: Async function to call when event is received
|
|
259
|
+
"""
|
|
260
|
+
if event_type not in self._handlers:
|
|
261
|
+
self._handlers[event_type] = []
|
|
262
|
+
self._handlers[event_type].append(handler)
|
|
263
|
+
|
|
264
|
+
def off(self, event_type: str, handler: AsyncEventHandler | None = None) -> None:
|
|
265
|
+
"""
|
|
266
|
+
Unregister an event handler.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
event_type: The event type
|
|
270
|
+
handler: Specific handler to remove (all if None)
|
|
271
|
+
"""
|
|
272
|
+
if event_type not in self._handlers:
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
if handler is None:
|
|
276
|
+
del self._handlers[event_type]
|
|
277
|
+
else:
|
|
278
|
+
self._handlers[event_type] = [h for h in self._handlers[event_type] if h != handler]
|
|
279
|
+
|
|
280
|
+
async def push_event(
|
|
281
|
+
self,
|
|
282
|
+
event_type: str,
|
|
283
|
+
brain_id: str,
|
|
284
|
+
data: dict[str, Any] | None = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""
|
|
287
|
+
Push an event to the server for broadcasting.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
event_type: Type of event (e.g., "neuron_created")
|
|
291
|
+
brain_id: The brain this event relates to
|
|
292
|
+
data: Event data
|
|
293
|
+
"""
|
|
294
|
+
if not self.is_connected:
|
|
295
|
+
raise ConnectionError("Not connected to sync server")
|
|
296
|
+
|
|
297
|
+
event = {
|
|
298
|
+
"type": event_type,
|
|
299
|
+
"brain_id": brain_id,
|
|
300
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
301
|
+
"data": data or {},
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await self._send({"action": "event", "event": event})
|
|
305
|
+
|
|
306
|
+
async def get_history(
|
|
307
|
+
self,
|
|
308
|
+
brain_id: str,
|
|
309
|
+
since: datetime | None = None,
|
|
310
|
+
) -> list[SyncEvent]:
|
|
311
|
+
"""
|
|
312
|
+
Get recent event history for a brain.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
brain_id: The brain ID
|
|
316
|
+
since: Only get events after this time
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
List of recent events
|
|
320
|
+
"""
|
|
321
|
+
if not self.is_connected:
|
|
322
|
+
raise ConnectionError("Not connected to sync server")
|
|
323
|
+
|
|
324
|
+
message: dict[str, Any] = {"action": "get_history", "brain_id": brain_id}
|
|
325
|
+
if since:
|
|
326
|
+
message["since"] = since.isoformat()
|
|
327
|
+
|
|
328
|
+
await self._send(message)
|
|
329
|
+
|
|
330
|
+
# Wait for response
|
|
331
|
+
if self._ws:
|
|
332
|
+
response = await self._ws.receive()
|
|
333
|
+
if response.type == aiohttp.WSMsgType.TEXT:
|
|
334
|
+
data = json.loads(response.data)
|
|
335
|
+
if data.get("type") == "history":
|
|
336
|
+
return [SyncEvent.from_dict(e) for e in data.get("events", [])]
|
|
337
|
+
|
|
338
|
+
return []
|
|
339
|
+
|
|
340
|
+
async def run_forever(self) -> None:
|
|
341
|
+
"""
|
|
342
|
+
Run the client, receiving and dispatching events.
|
|
343
|
+
|
|
344
|
+
This method blocks until disconnect() is called or
|
|
345
|
+
max reconnect attempts is exceeded.
|
|
346
|
+
"""
|
|
347
|
+
self._running = True
|
|
348
|
+
|
|
349
|
+
while self._running:
|
|
350
|
+
if not self.is_connected:
|
|
351
|
+
if self._auto_reconnect:
|
|
352
|
+
await self._try_reconnect()
|
|
353
|
+
else:
|
|
354
|
+
break
|
|
355
|
+
|
|
356
|
+
if not self._ws:
|
|
357
|
+
await asyncio.sleep(0.1)
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
message = await self._ws.receive()
|
|
362
|
+
|
|
363
|
+
if message.type == aiohttp.WSMsgType.TEXT:
|
|
364
|
+
data = json.loads(message.data)
|
|
365
|
+
await self._handle_message(data)
|
|
366
|
+
|
|
367
|
+
elif message.type in (
|
|
368
|
+
aiohttp.WSMsgType.CLOSED,
|
|
369
|
+
aiohttp.WSMsgType.ERROR,
|
|
370
|
+
):
|
|
371
|
+
self._state = SyncClientState.DISCONNECTED
|
|
372
|
+
if self._auto_reconnect:
|
|
373
|
+
continue
|
|
374
|
+
else:
|
|
375
|
+
break
|
|
376
|
+
|
|
377
|
+
except asyncio.CancelledError:
|
|
378
|
+
break
|
|
379
|
+
except Exception:
|
|
380
|
+
self._state = SyncClientState.DISCONNECTED
|
|
381
|
+
if self._auto_reconnect:
|
|
382
|
+
continue
|
|
383
|
+
else:
|
|
384
|
+
raise
|
|
385
|
+
|
|
386
|
+
async def _send(self, data: dict[str, Any]) -> None:
|
|
387
|
+
"""Send a message to the server."""
|
|
388
|
+
if self._ws:
|
|
389
|
+
await self._ws.send_str(json.dumps(data))
|
|
390
|
+
|
|
391
|
+
async def _handle_message(self, data: dict[str, Any]) -> None:
|
|
392
|
+
"""Handle an incoming message."""
|
|
393
|
+
event_type = data.get("type")
|
|
394
|
+
if not event_type:
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
# Convert to SyncEvent
|
|
398
|
+
event = SyncEvent.from_dict(data)
|
|
399
|
+
|
|
400
|
+
# Skip events we originated
|
|
401
|
+
if event.source_client_id == self._client_id:
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
# Dispatch to handlers
|
|
405
|
+
handlers = self._handlers.get(event_type, [])
|
|
406
|
+
handlers.extend(self._handlers.get("*", [])) # Wildcard handlers
|
|
407
|
+
|
|
408
|
+
for handler in handlers:
|
|
409
|
+
try:
|
|
410
|
+
result = handler(event)
|
|
411
|
+
if asyncio.iscoroutine(result):
|
|
412
|
+
await result
|
|
413
|
+
except Exception:
|
|
414
|
+
pass # Don't let handler errors crash the client
|
|
415
|
+
|
|
416
|
+
async def _try_reconnect(self) -> None:
|
|
417
|
+
"""Attempt to reconnect to the server."""
|
|
418
|
+
if self._max_reconnect_attempts > 0:
|
|
419
|
+
if self._reconnect_attempts >= self._max_reconnect_attempts:
|
|
420
|
+
self._running = False
|
|
421
|
+
return
|
|
422
|
+
|
|
423
|
+
self._state = SyncClientState.RECONNECTING
|
|
424
|
+
self._reconnect_attempts += 1
|
|
425
|
+
|
|
426
|
+
# Exponential backoff
|
|
427
|
+
delay = self._reconnect_delay * (2 ** (self._reconnect_attempts - 1))
|
|
428
|
+
delay = min(delay, 60.0) # Cap at 60 seconds
|
|
429
|
+
|
|
430
|
+
await asyncio.sleep(delay)
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
await self.connect()
|
|
434
|
+
except Exception:
|
|
435
|
+
pass # Will retry on next loop iteration
|