roampal 0.1.4__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.
Files changed (44) hide show
  1. roampal/__init__.py +29 -0
  2. roampal/__main__.py +6 -0
  3. roampal/backend/__init__.py +1 -0
  4. roampal/backend/modules/__init__.py +1 -0
  5. roampal/backend/modules/memory/__init__.py +43 -0
  6. roampal/backend/modules/memory/chromadb_adapter.py +623 -0
  7. roampal/backend/modules/memory/config.py +102 -0
  8. roampal/backend/modules/memory/content_graph.py +543 -0
  9. roampal/backend/modules/memory/context_service.py +455 -0
  10. roampal/backend/modules/memory/embedding_service.py +96 -0
  11. roampal/backend/modules/memory/knowledge_graph_service.py +1052 -0
  12. roampal/backend/modules/memory/memory_bank_service.py +433 -0
  13. roampal/backend/modules/memory/memory_types.py +296 -0
  14. roampal/backend/modules/memory/outcome_service.py +400 -0
  15. roampal/backend/modules/memory/promotion_service.py +473 -0
  16. roampal/backend/modules/memory/routing_service.py +444 -0
  17. roampal/backend/modules/memory/scoring_service.py +324 -0
  18. roampal/backend/modules/memory/search_service.py +646 -0
  19. roampal/backend/modules/memory/tests/__init__.py +1 -0
  20. roampal/backend/modules/memory/tests/conftest.py +12 -0
  21. roampal/backend/modules/memory/tests/unit/__init__.py +1 -0
  22. roampal/backend/modules/memory/tests/unit/conftest.py +7 -0
  23. roampal/backend/modules/memory/tests/unit/test_knowledge_graph_service.py +517 -0
  24. roampal/backend/modules/memory/tests/unit/test_memory_bank_service.py +504 -0
  25. roampal/backend/modules/memory/tests/unit/test_outcome_service.py +485 -0
  26. roampal/backend/modules/memory/tests/unit/test_scoring_service.py +255 -0
  27. roampal/backend/modules/memory/tests/unit/test_search_service.py +413 -0
  28. roampal/backend/modules/memory/tests/unit/test_unified_memory_system.py +418 -0
  29. roampal/backend/modules/memory/unified_memory_system.py +1277 -0
  30. roampal/cli.py +638 -0
  31. roampal/hooks/__init__.py +16 -0
  32. roampal/hooks/session_manager.py +587 -0
  33. roampal/hooks/stop_hook.py +176 -0
  34. roampal/hooks/user_prompt_submit_hook.py +103 -0
  35. roampal/mcp/__init__.py +7 -0
  36. roampal/mcp/server.py +611 -0
  37. roampal/server/__init__.py +7 -0
  38. roampal/server/main.py +744 -0
  39. roampal-0.1.4.dist-info/METADATA +179 -0
  40. roampal-0.1.4.dist-info/RECORD +44 -0
  41. roampal-0.1.4.dist-info/WHEEL +5 -0
  42. roampal-0.1.4.dist-info/entry_points.txt +2 -0
  43. roampal-0.1.4.dist-info/licenses/LICENSE +190 -0
  44. roampal-0.1.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,504 @@
1
+ """
2
+ Unit Tests for MemoryBankService
3
+
4
+ Tests the extracted memory bank operations.
5
+ """
6
+
7
+ import sys
8
+ import os
9
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..', '..')))
10
+
11
+ import json
12
+ import pytest
13
+ from unittest.mock import MagicMock, AsyncMock
14
+ from datetime import datetime
15
+
16
+ from roampal.backend.modules.memory.memory_bank_service import MemoryBankService
17
+ from roampal.backend.modules.memory.config import MemoryConfig
18
+
19
+
20
+ class TestMemoryBankServiceInit:
21
+ """Test MemoryBankService initialization."""
22
+
23
+ def test_init_with_defaults(self):
24
+ """Should initialize with default config."""
25
+ collection = MagicMock()
26
+ service = MemoryBankService(
27
+ collection=collection,
28
+ embed_fn=AsyncMock()
29
+ )
30
+ assert service.config is not None
31
+ assert service.MAX_ITEMS == 500
32
+
33
+ def test_init_with_custom_config(self):
34
+ """Should use custom config."""
35
+ config = MemoryConfig(promotion_score_threshold=0.8)
36
+ service = MemoryBankService(
37
+ collection=MagicMock(),
38
+ embed_fn=AsyncMock(),
39
+ config=config
40
+ )
41
+ assert service.config.promotion_score_threshold == 0.8
42
+
43
+
44
+ class TestStore:
45
+ """Test memory storage."""
46
+
47
+ @pytest.fixture
48
+ def mock_collection(self):
49
+ coll = MagicMock()
50
+ coll.collection = MagicMock()
51
+ coll.collection.count = MagicMock(return_value=10)
52
+ coll.upsert_vectors = AsyncMock()
53
+ return coll
54
+
55
+ @pytest.fixture
56
+ def service(self, mock_collection):
57
+ return MemoryBankService(
58
+ collection=mock_collection,
59
+ embed_fn=AsyncMock(return_value=[0.1] * 384)
60
+ )
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_store_basic(self, service, mock_collection):
64
+ """Should store memory with correct metadata."""
65
+ doc_id = await service.store(
66
+ text="User prefers dark mode",
67
+ tags=["preference"]
68
+ )
69
+
70
+ assert doc_id.startswith("memory_bank_")
71
+ mock_collection.upsert_vectors.assert_called_once()
72
+
73
+ call_args = mock_collection.upsert_vectors.call_args
74
+ metadata = call_args[1]["metadatas"][0]
75
+
76
+ assert metadata["text"] == "User prefers dark mode"
77
+ assert metadata["status"] == "active"
78
+ assert metadata["score"] == 1.0
79
+ assert json.loads(metadata["tags"]) == ["preference"]
80
+
81
+ @pytest.mark.asyncio
82
+ async def test_store_with_importance_confidence(self, service, mock_collection):
83
+ """Should store with custom importance/confidence."""
84
+ await service.store(
85
+ text="Critical info",
86
+ tags=["identity"],
87
+ importance=0.95,
88
+ confidence=0.9
89
+ )
90
+
91
+ call_args = mock_collection.upsert_vectors.call_args
92
+ metadata = call_args[1]["metadatas"][0]
93
+
94
+ assert metadata["importance"] == 0.95
95
+ assert metadata["confidence"] == 0.9
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_store_capacity_check(self, service, mock_collection):
99
+ """Should reject when at capacity."""
100
+ mock_collection.collection.count = MagicMock(return_value=500)
101
+
102
+ with pytest.raises(ValueError, match="capacity"):
103
+ await service.store("Test", ["test"])
104
+
105
+
106
+ class TestUpdate:
107
+ """Test memory update with archiving."""
108
+
109
+ @pytest.fixture
110
+ def mock_collection(self):
111
+ coll = MagicMock()
112
+ coll.get_fragment = MagicMock(return_value={
113
+ "content": "old content",
114
+ "metadata": {
115
+ "text": "old content",
116
+ "tags": '["identity"]',
117
+ "importance": 0.7,
118
+ "confidence": 0.7
119
+ }
120
+ })
121
+ coll.upsert_vectors = AsyncMock()
122
+ coll.collection = MagicMock()
123
+ coll.collection.count = MagicMock(return_value=10)
124
+ return coll
125
+
126
+ @pytest.fixture
127
+ def service(self, mock_collection):
128
+ return MemoryBankService(
129
+ collection=mock_collection,
130
+ embed_fn=AsyncMock(return_value=[0.1] * 384)
131
+ )
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_update_archives_old(self, service, mock_collection):
135
+ """Should archive old version when updating."""
136
+ await service.update(
137
+ doc_id="memory_bank_test123",
138
+ new_text="new content",
139
+ reason="correction"
140
+ )
141
+
142
+ # Should have 2 upsert calls: archive + update
143
+ assert mock_collection.upsert_vectors.call_count == 2
144
+
145
+ # Check archive call
146
+ archive_call = mock_collection.upsert_vectors.call_args_list[0]
147
+ archive_id = archive_call[1]["ids"][0]
148
+ archive_metadata = archive_call[1]["metadatas"][0]
149
+
150
+ assert "archived" in archive_id
151
+ assert archive_metadata["status"] == "archived"
152
+ assert archive_metadata["archive_reason"] == "correction"
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_update_preserves_metadata(self, service, mock_collection):
156
+ """Should preserve original metadata fields."""
157
+ await service.update(
158
+ doc_id="memory_bank_test123",
159
+ new_text="new content"
160
+ )
161
+
162
+ update_call = mock_collection.upsert_vectors.call_args_list[1]
163
+ metadata = update_call[1]["metadatas"][0]
164
+
165
+ assert metadata["importance"] == 0.7
166
+ assert metadata["text"] == "new content"
167
+
168
+ @pytest.mark.asyncio
169
+ async def test_update_not_found_creates_new(self, service, mock_collection):
170
+ """Should create new memory if not found."""
171
+ mock_collection.get_fragment = MagicMock(return_value=None)
172
+
173
+ doc_id = await service.update(
174
+ doc_id="memory_bank_nonexistent",
175
+ new_text="new memory"
176
+ )
177
+
178
+ assert doc_id.startswith("memory_bank_")
179
+
180
+
181
+ class TestArchive:
182
+ """Test memory archiving."""
183
+
184
+ @pytest.fixture
185
+ def mock_collection(self):
186
+ coll = MagicMock()
187
+ coll.get_fragment = MagicMock(return_value={
188
+ "content": "test",
189
+ "metadata": {"status": "active"}
190
+ })
191
+ coll.update_fragment_metadata = MagicMock()
192
+ return coll
193
+
194
+ @pytest.fixture
195
+ def service(self, mock_collection):
196
+ return MemoryBankService(
197
+ collection=mock_collection,
198
+ embed_fn=AsyncMock()
199
+ )
200
+
201
+ @pytest.mark.asyncio
202
+ async def test_archive_success(self, service, mock_collection):
203
+ """Should archive memory successfully."""
204
+ result = await service.archive(
205
+ doc_id="memory_bank_test123",
206
+ reason="outdated"
207
+ )
208
+
209
+ assert result is True
210
+ mock_collection.update_fragment_metadata.assert_called_once()
211
+
212
+ call_args = mock_collection.update_fragment_metadata.call_args
213
+ metadata = call_args[0][1]
214
+ assert metadata["status"] == "archived"
215
+ assert metadata["archive_reason"] == "outdated"
216
+
217
+ @pytest.mark.asyncio
218
+ async def test_archive_not_found(self, service, mock_collection):
219
+ """Should return False if not found."""
220
+ mock_collection.get_fragment = MagicMock(return_value=None)
221
+
222
+ result = await service.archive("nonexistent")
223
+ assert result is False
224
+
225
+
226
+ class TestSearch:
227
+ """Test memory search."""
228
+
229
+ @pytest.fixture
230
+ def mock_collection(self):
231
+ coll = MagicMock()
232
+ coll.list_all_ids = MagicMock(return_value=[
233
+ "memory_bank_1", "memory_bank_2", "memory_bank_3"
234
+ ])
235
+
236
+ def get_fragment_side_effect(doc_id):
237
+ if doc_id == "memory_bank_1":
238
+ return {
239
+ "content": "User name is John",
240
+ "metadata": {"status": "active", "tags": '["identity"]'}
241
+ }
242
+ elif doc_id == "memory_bank_2":
243
+ return {
244
+ "content": "Prefers dark mode",
245
+ "metadata": {"status": "active", "tags": '["preference"]'}
246
+ }
247
+ elif doc_id == "memory_bank_3":
248
+ return {
249
+ "content": "Old info",
250
+ "metadata": {"status": "archived", "tags": '["identity"]'}
251
+ }
252
+ return None
253
+
254
+ coll.get_fragment = MagicMock(side_effect=get_fragment_side_effect)
255
+ return coll
256
+
257
+ @pytest.fixture
258
+ def service(self, mock_collection):
259
+ return MemoryBankService(
260
+ collection=mock_collection,
261
+ embed_fn=AsyncMock()
262
+ )
263
+
264
+ @pytest.mark.asyncio
265
+ async def test_search_excludes_archived_by_default(self, service):
266
+ """Should exclude archived memories by default."""
267
+ results = await service.search()
268
+ assert len(results) == 2
269
+
270
+ @pytest.mark.asyncio
271
+ async def test_search_includes_archived_when_requested(self, service):
272
+ """Should include archived when requested."""
273
+ results = await service.search(include_archived=True)
274
+ assert len(results) == 3
275
+
276
+ @pytest.mark.asyncio
277
+ async def test_search_filters_by_tags(self, service):
278
+ """Should filter by tags."""
279
+ results = await service.search(tags=["identity"])
280
+ assert len(results) == 1
281
+ assert results[0]["metadata"]["tags"] == '["identity"]'
282
+
283
+
284
+ class TestRestore:
285
+ """Test memory restoration."""
286
+
287
+ @pytest.fixture
288
+ def mock_collection(self):
289
+ coll = MagicMock()
290
+ coll.get_fragment = MagicMock(return_value={
291
+ "content": "test",
292
+ "metadata": {"status": "archived"}
293
+ })
294
+ coll.update_fragment_metadata = MagicMock()
295
+ return coll
296
+
297
+ @pytest.fixture
298
+ def service(self, mock_collection):
299
+ return MemoryBankService(
300
+ collection=mock_collection,
301
+ embed_fn=AsyncMock()
302
+ )
303
+
304
+ @pytest.mark.asyncio
305
+ async def test_restore_success(self, service, mock_collection):
306
+ """Should restore archived memory."""
307
+ result = await service.restore("memory_bank_test123")
308
+
309
+ assert result is True
310
+ call_args = mock_collection.update_fragment_metadata.call_args
311
+ metadata = call_args[0][1]
312
+ assert metadata["status"] == "active"
313
+ assert metadata["restored_by"] == "user"
314
+
315
+ @pytest.mark.asyncio
316
+ async def test_restore_not_found(self, service, mock_collection):
317
+ """Should return False if not found."""
318
+ mock_collection.get_fragment = MagicMock(return_value=None)
319
+
320
+ result = await service.restore("nonexistent")
321
+ assert result is False
322
+
323
+
324
+ class TestDelete:
325
+ """Test memory deletion."""
326
+
327
+ @pytest.fixture
328
+ def mock_collection(self):
329
+ coll = MagicMock()
330
+ coll.delete_vectors = MagicMock()
331
+ return coll
332
+
333
+ @pytest.fixture
334
+ def service(self, mock_collection):
335
+ return MemoryBankService(
336
+ collection=mock_collection,
337
+ embed_fn=AsyncMock()
338
+ )
339
+
340
+ @pytest.mark.asyncio
341
+ async def test_delete_success(self, service, mock_collection):
342
+ """Should delete memory successfully."""
343
+ result = await service.delete("memory_bank_test123")
344
+
345
+ assert result is True
346
+ mock_collection.delete_vectors.assert_called_with(["memory_bank_test123"])
347
+
348
+ @pytest.mark.asyncio
349
+ async def test_delete_failure(self, service, mock_collection):
350
+ """Should return False on error."""
351
+ mock_collection.delete_vectors = MagicMock(side_effect=Exception("Test error"))
352
+
353
+ result = await service.delete("memory_bank_test123")
354
+ assert result is False
355
+
356
+
357
+ class TestListAll:
358
+ """Test listing all memories."""
359
+
360
+ @pytest.fixture
361
+ def mock_collection(self):
362
+ coll = MagicMock()
363
+ coll.list_all_ids = MagicMock(return_value=["m1", "m2", "m3"])
364
+
365
+ def get_fragment_side_effect(doc_id):
366
+ return {
367
+ "content": f"content_{doc_id}",
368
+ "metadata": {
369
+ "status": "active" if doc_id != "m3" else "archived",
370
+ "tags": '["identity"]' if doc_id == "m1" else '["preference"]'
371
+ }
372
+ }
373
+
374
+ coll.get_fragment = MagicMock(side_effect=get_fragment_side_effect)
375
+ return coll
376
+
377
+ @pytest.fixture
378
+ def service(self, mock_collection):
379
+ return MemoryBankService(
380
+ collection=mock_collection,
381
+ embed_fn=AsyncMock()
382
+ )
383
+
384
+ def test_list_all_excludes_archived(self, service):
385
+ """Should exclude archived by default."""
386
+ results = service.list_all()
387
+ assert len(results) == 2
388
+
389
+ def test_list_all_includes_archived(self, service):
390
+ """Should include archived when requested."""
391
+ results = service.list_all(include_archived=True)
392
+ assert len(results) == 3
393
+
394
+ def test_list_all_filters_tags(self, service):
395
+ """Should filter by tags."""
396
+ results = service.list_all(tags=["identity"])
397
+ assert len(results) == 1
398
+
399
+
400
+ class TestStats:
401
+ """Test statistics retrieval."""
402
+
403
+ @pytest.fixture
404
+ def mock_collection(self):
405
+ coll = MagicMock()
406
+ coll.list_all_ids = MagicMock(return_value=["m1", "m2", "m3"])
407
+
408
+ def get_fragment_side_effect(doc_id):
409
+ if doc_id == "m1":
410
+ return {
411
+ "content": "identity",
412
+ "metadata": {
413
+ "status": "active",
414
+ "tags": '["identity"]',
415
+ "importance": 0.9,
416
+ "confidence": 0.8
417
+ }
418
+ }
419
+ elif doc_id == "m2":
420
+ return {
421
+ "content": "preference",
422
+ "metadata": {
423
+ "status": "active",
424
+ "tags": '["preference", "identity"]',
425
+ "importance": 0.7,
426
+ "confidence": 0.7
427
+ }
428
+ }
429
+ elif doc_id == "m3":
430
+ return {
431
+ "content": "archived",
432
+ "metadata": {
433
+ "status": "archived",
434
+ "tags": '["old"]',
435
+ "importance": 0.5,
436
+ "confidence": 0.5
437
+ }
438
+ }
439
+ return None
440
+
441
+ coll.get_fragment = MagicMock(side_effect=get_fragment_side_effect)
442
+ return coll
443
+
444
+ @pytest.fixture
445
+ def service(self, mock_collection):
446
+ return MemoryBankService(
447
+ collection=mock_collection,
448
+ embed_fn=AsyncMock()
449
+ )
450
+
451
+ def test_get_stats(self, service):
452
+ """Should return correct statistics."""
453
+ stats = service.get_stats()
454
+
455
+ assert stats["total"] == 3
456
+ assert stats["active"] == 2
457
+ assert stats["archived"] == 1
458
+ assert stats["capacity"] == 500
459
+ assert stats["tag_counts"]["identity"] == 2
460
+ assert stats["tag_counts"]["preference"] == 1
461
+ assert stats["avg_importance"] == 0.8 # (0.9 + 0.7) / 2
462
+ assert stats["avg_confidence"] == 0.75 # (0.8 + 0.7) / 2
463
+
464
+
465
+ class TestIncrementMention:
466
+ """Test mention count tracking."""
467
+
468
+ @pytest.fixture
469
+ def mock_collection(self):
470
+ coll = MagicMock()
471
+ coll.get_fragment = MagicMock(return_value={
472
+ "content": "test",
473
+ "metadata": {"mentioned_count": 5}
474
+ })
475
+ coll.update_fragment_metadata = MagicMock()
476
+ return coll
477
+
478
+ @pytest.fixture
479
+ def service(self, mock_collection):
480
+ return MemoryBankService(
481
+ collection=mock_collection,
482
+ embed_fn=AsyncMock()
483
+ )
484
+
485
+ def test_increment_mention(self, service, mock_collection):
486
+ """Should increment mention count."""
487
+ result = service.increment_mention("memory_bank_test123")
488
+
489
+ assert result is True
490
+ call_args = mock_collection.update_fragment_metadata.call_args
491
+ metadata = call_args[0][1]
492
+ assert metadata["mentioned_count"] == 6
493
+ assert "last_mentioned" in metadata
494
+
495
+ def test_increment_not_found(self, service, mock_collection):
496
+ """Should return False if not found."""
497
+ mock_collection.get_fragment = MagicMock(return_value=None)
498
+
499
+ result = service.increment_mention("nonexistent")
500
+ assert result is False
501
+
502
+
503
+ if __name__ == "__main__":
504
+ pytest.main([__file__, "-v"])