superlocalmemory 2.7.3 → 2.7.5

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 - Signal Inference Engine Tests (v2.7.4)
4
+ Copyright (c) 2026 Varun Pratap Bhardwaj
5
+ Licensed under MIT License
6
+
7
+ Tests for the implicit feedback signal inference system.
8
+ """
9
+
10
+ import time
11
+ import threading
12
+ import pytest
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ # Ensure mcp_server module is importable for RecallBuffer
17
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
18
+
19
+
20
+ class TestRecallBuffer:
21
+ """Test the _RecallBuffer class from mcp_server."""
22
+
23
+ def _make_buffer(self):
24
+ """Create a fresh RecallBuffer for testing."""
25
+ # Import directly to avoid starting MCP server
26
+ from importlib import import_module
27
+ import types
28
+
29
+ # Create a minimal RecallBuffer instance by duplicating the class logic
30
+ # without importing the full mcp_server (which requires MCP deps)
31
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
32
+
33
+ class RecallBuffer:
34
+ def __init__(self):
35
+ self._lock = threading.Lock()
36
+ self._last_recall = {}
37
+ self._global_last = None
38
+ self._signal_timestamps = {}
39
+ self._recent_result_ids = set()
40
+ self._recall_count = 0
41
+ self._positive_threshold = 300.0
42
+ self._inter_recall_times = []
43
+
44
+ def record_recall(self, query, result_ids, agent_id="mcp-client"):
45
+ now = time.time()
46
+ signals = []
47
+ with self._lock:
48
+ self._recall_count += 1
49
+ result_id_set = set(result_ids)
50
+ self._recent_result_ids = result_id_set
51
+ current = {
52
+ "query": query, "result_ids": result_ids,
53
+ "result_id_set": result_id_set, "timestamp": now,
54
+ "agent_id": agent_id,
55
+ }
56
+ prev = self._last_recall.get(agent_id)
57
+ if prev:
58
+ time_gap = now - prev["timestamp"]
59
+ self._inter_recall_times.append(time_gap)
60
+ if len(self._inter_recall_times) > 100:
61
+ self._inter_recall_times = self._inter_recall_times[-100:]
62
+ if len(self._inter_recall_times) >= 10:
63
+ sorted_times = sorted(self._inter_recall_times)
64
+ median = sorted_times[len(sorted_times) // 2]
65
+ self._positive_threshold = max(60.0, min(median * 0.8, 1800.0))
66
+ if time_gap < 30.0 and query != prev["query"]:
67
+ for mid in prev["result_ids"][:5]:
68
+ signals.append({
69
+ "memory_id": mid,
70
+ "signal_type": "implicit_negative_requick",
71
+ "query": prev["query"],
72
+ "rank_position": prev["result_ids"].index(mid) + 1,
73
+ })
74
+ elif time_gap > self._positive_threshold:
75
+ for mid in prev["result_ids"][:3]:
76
+ signals.append({
77
+ "memory_id": mid,
78
+ "signal_type": "implicit_positive_timegap",
79
+ "query": prev["query"],
80
+ "rank_position": prev["result_ids"].index(mid) + 1,
81
+ })
82
+ overlap = result_id_set & prev["result_id_set"]
83
+ for mid in overlap:
84
+ signals.append({
85
+ "memory_id": mid,
86
+ "signal_type": "implicit_positive_reaccess",
87
+ "query": query,
88
+ })
89
+ global_prev = self._global_last
90
+ if global_prev and global_prev["agent_id"] != agent_id:
91
+ cross_overlap = result_id_set & global_prev["result_id_set"]
92
+ for mid in cross_overlap:
93
+ signals.append({
94
+ "memory_id": mid,
95
+ "signal_type": "implicit_positive_cross_tool",
96
+ "query": query,
97
+ })
98
+ self._last_recall[agent_id] = current
99
+ self._global_last = current
100
+ return signals
101
+
102
+ def check_post_action(self, memory_id, action):
103
+ with self._lock:
104
+ if memory_id not in self._recent_result_ids:
105
+ return None
106
+ if action == "update":
107
+ return {"memory_id": memory_id, "signal_type": "implicit_positive_post_update",
108
+ "query": self._global_last["query"] if self._global_last else ""}
109
+ elif action == "delete":
110
+ return {"memory_id": memory_id, "signal_type": "implicit_negative_post_delete",
111
+ "query": self._global_last["query"] if self._global_last else ""}
112
+ return None
113
+
114
+ def check_rate_limit(self, agent_id, max_per_minute=5):
115
+ now = time.time()
116
+ with self._lock:
117
+ if agent_id not in self._signal_timestamps:
118
+ self._signal_timestamps[agent_id] = []
119
+ self._signal_timestamps[agent_id] = [
120
+ ts for ts in self._signal_timestamps[agent_id] if now - ts < 60.0
121
+ ]
122
+ if len(self._signal_timestamps[agent_id]) >= max_per_minute:
123
+ return False
124
+ self._signal_timestamps[agent_id].append(now)
125
+ return True
126
+
127
+ def get_recall_count(self):
128
+ with self._lock:
129
+ return self._recall_count
130
+
131
+ def get_stats(self):
132
+ with self._lock:
133
+ return {
134
+ "recall_count": self._recall_count,
135
+ "tracked_agents": len(self._last_recall),
136
+ "positive_threshold_s": round(self._positive_threshold, 1),
137
+ "recent_results_count": len(self._recent_result_ids),
138
+ }
139
+
140
+ return RecallBuffer()
141
+
142
+ def test_first_recall_no_signals(self):
143
+ """First recall should produce no signals (nothing to compare with)."""
144
+ buf = self._make_buffer()
145
+ signals = buf.record_recall("test query", [1, 2, 3])
146
+ assert signals == []
147
+ assert buf.get_recall_count() == 1
148
+
149
+ def test_quick_requery_negative_signals(self):
150
+ """Quick re-query with different query should produce negative signals."""
151
+ buf = self._make_buffer()
152
+ buf.record_recall("first query", [10, 20, 30])
153
+ # Simulate quick re-query (no sleep needed — time gap is ~0)
154
+ signals = buf.record_recall("different query", [40, 50])
155
+ negative_signals = [s for s in signals if s["signal_type"] == "implicit_negative_requick"]
156
+ assert len(negative_signals) > 0
157
+ # Should target previous results (10, 20, 30)
158
+ neg_ids = {s["memory_id"] for s in negative_signals}
159
+ assert neg_ids.issubset({10, 20, 30})
160
+
161
+ def test_same_query_no_negative(self):
162
+ """Same exact query repeated quickly should NOT produce negative signals."""
163
+ buf = self._make_buffer()
164
+ buf.record_recall("same query", [10, 20])
165
+ signals = buf.record_recall("same query", [10, 20])
166
+ negative_signals = [s for s in signals if s["signal_type"] == "implicit_negative_requick"]
167
+ assert len(negative_signals) == 0
168
+
169
+ def test_reaccess_positive_signals(self):
170
+ """Same memory appearing in consecutive recalls = positive."""
171
+ buf = self._make_buffer()
172
+ buf.record_recall("query a", [10, 20, 30])
173
+ signals = buf.record_recall("query b", [20, 40, 50])
174
+ reaccess = [s for s in signals if s["signal_type"] == "implicit_positive_reaccess"]
175
+ assert len(reaccess) == 1
176
+ assert reaccess[0]["memory_id"] == 20
177
+
178
+ def test_cross_tool_positive_signals(self):
179
+ """Same memory recalled by different agents = cross-tool positive."""
180
+ buf = self._make_buffer()
181
+ buf.record_recall("query", [10, 20], agent_id="claude")
182
+ signals = buf.record_recall("query", [20, 30], agent_id="cursor")
183
+ cross_tool = [s for s in signals if s["signal_type"] == "implicit_positive_cross_tool"]
184
+ assert len(cross_tool) == 1
185
+ assert cross_tool[0]["memory_id"] == 20
186
+
187
+ def test_post_action_update_tracked(self):
188
+ """Update after recall should generate positive signal."""
189
+ buf = self._make_buffer()
190
+ buf.record_recall("query", [10, 20, 30])
191
+ signal = buf.check_post_action(20, "update")
192
+ assert signal is not None
193
+ assert signal["signal_type"] == "implicit_positive_post_update"
194
+ assert signal["memory_id"] == 20
195
+
196
+ def test_post_action_delete_tracked(self):
197
+ """Delete after recall should generate negative signal."""
198
+ buf = self._make_buffer()
199
+ buf.record_recall("query", [10, 20, 30])
200
+ signal = buf.check_post_action(10, "delete")
201
+ assert signal is not None
202
+ assert signal["signal_type"] == "implicit_negative_post_delete"
203
+
204
+ def test_post_action_unrelated_memory_ignored(self):
205
+ """Action on memory NOT in recent results should be ignored."""
206
+ buf = self._make_buffer()
207
+ buf.record_recall("query", [10, 20, 30])
208
+ signal = buf.check_post_action(999, "update")
209
+ assert signal is None
210
+
211
+ def test_rate_limiting(self):
212
+ """Rate limiter should cap signals per agent per minute."""
213
+ buf = self._make_buffer()
214
+ agent = "test-agent"
215
+ # First 5 should be allowed
216
+ for i in range(5):
217
+ assert buf.check_rate_limit(agent) is True
218
+ # 6th should be blocked
219
+ assert buf.check_rate_limit(agent) is False
220
+
221
+ def test_rate_limiting_different_agents(self):
222
+ """Different agents should have independent rate limits."""
223
+ buf = self._make_buffer()
224
+ for i in range(5):
225
+ assert buf.check_rate_limit("agent1") is True
226
+ assert buf.check_rate_limit("agent1") is False
227
+ # Agent2 should still be allowed
228
+ assert buf.check_rate_limit("agent2") is True
229
+
230
+ def test_recall_count_increments(self):
231
+ """Recall count should increment with each recall."""
232
+ buf = self._make_buffer()
233
+ assert buf.get_recall_count() == 0
234
+ buf.record_recall("q1", [1])
235
+ assert buf.get_recall_count() == 1
236
+ buf.record_recall("q2", [2])
237
+ assert buf.get_recall_count() == 2
238
+
239
+ def test_negative_signals_cap_at_5(self):
240
+ """Negative signals should only target top 5 results."""
241
+ buf = self._make_buffer()
242
+ buf.record_recall("first", list(range(1, 11))) # 10 results
243
+ signals = buf.record_recall("different", [99])
244
+ negative = [s for s in signals if s["signal_type"] == "implicit_negative_requick"]
245
+ assert len(negative) <= 5
246
+
247
+ def test_stats_output(self):
248
+ """Stats should report correct values."""
249
+ buf = self._make_buffer()
250
+ buf.record_recall("q", [1, 2, 3])
251
+ stats = buf.get_stats()
252
+ assert stats["recall_count"] == 1
253
+ assert stats["tracked_agents"] == 1
254
+ assert stats["recent_results_count"] == 3
255
+ assert stats["positive_threshold_s"] == 300.0
256
+
257
+ def test_thread_safety(self):
258
+ """Buffer should handle concurrent access without errors."""
259
+ buf = self._make_buffer()
260
+ errors = []
261
+
262
+ def worker(agent_id):
263
+ try:
264
+ for i in range(20):
265
+ buf.record_recall(f"query_{i}", [i, i+1], agent_id=agent_id)
266
+ buf.check_rate_limit(agent_id)
267
+ except Exception as e:
268
+ errors.append(e)
269
+
270
+ threads = [threading.Thread(target=worker, args=(f"agent_{i}",)) for i in range(5)]
271
+ for t in threads:
272
+ t.start()
273
+ for t in threads:
274
+ t.join(timeout=10)
275
+
276
+ assert errors == [], f"Thread safety errors: {errors}"
277
+ assert buf.get_recall_count() == 100 # 5 agents x 20 recalls
278
+
279
+
280
+ class TestFeedbackCollectorImplicit:
281
+ """Test the new implicit signal methods in FeedbackCollector."""
282
+
283
+ def test_record_implicit_signal_valid(self):
284
+ """Valid implicit signal should be stored."""
285
+ sys.path.insert(0, str(Path(__file__).parent.parent))
286
+ from feedback_collector import FeedbackCollector
287
+ fc = FeedbackCollector()
288
+ result = fc.record_implicit_signal(
289
+ memory_id=42,
290
+ query="test query",
291
+ signal_type="implicit_positive_timegap",
292
+ )
293
+ # Should return row ID or None (depends on DB availability)
294
+ # Just verify it doesn't crash
295
+ assert result is None or isinstance(result, int)
296
+
297
+ def test_record_implicit_signal_invalid_type(self):
298
+ """Invalid signal type should return None."""
299
+ sys.path.insert(0, str(Path(__file__).parent.parent))
300
+ from feedback_collector import FeedbackCollector
301
+ fc = FeedbackCollector()
302
+ result = fc.record_implicit_signal(
303
+ memory_id=42,
304
+ query="test",
305
+ signal_type="totally_fake_type",
306
+ )
307
+ assert result is None
308
+
309
+ def test_record_dashboard_feedback_valid(self):
310
+ """Valid dashboard feedback should be stored."""
311
+ sys.path.insert(0, str(Path(__file__).parent.parent))
312
+ from feedback_collector import FeedbackCollector
313
+ fc = FeedbackCollector()
314
+ result = fc.record_dashboard_feedback(
315
+ memory_id=42,
316
+ query="test query",
317
+ feedback_type="thumbs_up",
318
+ )
319
+ assert result is None or isinstance(result, int)
320
+
321
+ def test_record_dashboard_feedback_invalid_type(self):
322
+ """Invalid feedback type should return None."""
323
+ sys.path.insert(0, str(Path(__file__).parent.parent))
324
+ from feedback_collector import FeedbackCollector
325
+ fc = FeedbackCollector()
326
+ result = fc.record_dashboard_feedback(
327
+ memory_id=42,
328
+ query="test",
329
+ feedback_type="invalid_type",
330
+ )
331
+ assert result is None
332
+
333
+ def test_signal_values_complete(self):
334
+ """All declared signal types should have numeric values."""
335
+ sys.path.insert(0, str(Path(__file__).parent.parent))
336
+ from feedback_collector import FeedbackCollector
337
+ fc = FeedbackCollector()
338
+ for signal_type, value in fc.SIGNAL_VALUES.items():
339
+ assert isinstance(value, (int, float)), f"{signal_type} has non-numeric value"
340
+ assert 0.0 <= value <= 1.0, f"{signal_type} value {value} out of range"
341
+
342
+
343
+ class TestFeatureExpansion:
344
+ """Test the 10→12 feature vector expansion."""
345
+
346
+ def test_feature_count_is_12(self):
347
+ """Feature vector should have 12 dimensions."""
348
+ sys.path.insert(0, str(Path(__file__).parent.parent))
349
+ from feature_extractor import FEATURE_NAMES, NUM_FEATURES
350
+ assert NUM_FEATURES == 12
351
+ assert len(FEATURE_NAMES) == 12
352
+
353
+ def test_new_features_present(self):
354
+ """signal_count and avg_signal_value should be in feature names."""
355
+ sys.path.insert(0, str(Path(__file__).parent.parent))
356
+ from feature_extractor import FEATURE_NAMES
357
+ assert 'signal_count' in FEATURE_NAMES
358
+ assert 'avg_signal_value' in FEATURE_NAMES
359
+ assert FEATURE_NAMES.index('signal_count') == 10
360
+ assert FEATURE_NAMES.index('avg_signal_value') == 11
361
+
362
+ def test_extract_features_returns_12(self):
363
+ """Extract should return 12-element vector."""
364
+ sys.path.insert(0, str(Path(__file__).parent.parent))
365
+ from feature_extractor import FeatureExtractor
366
+ fe = FeatureExtractor()
367
+ features = fe.extract_features(
368
+ {'id': 1, 'content': 'test', 'importance': 5},
369
+ 'test'
370
+ )
371
+ assert len(features) == 12
372
+
373
+ def test_signal_features_with_stats(self):
374
+ """Signal features should use provided stats."""
375
+ sys.path.insert(0, str(Path(__file__).parent.parent))
376
+ from feature_extractor import FeatureExtractor
377
+ fe = FeatureExtractor()
378
+ fe.set_context(signal_stats={
379
+ '42': {'count': 8, 'avg_value': 0.9},
380
+ })
381
+ features = fe.extract_features(
382
+ {'id': 42, 'content': 'test', 'importance': 5},
383
+ 'test'
384
+ )
385
+ assert features[10] == 0.8 # count=8, 8/10=0.8
386
+ assert features[11] == 0.9 # avg_value=0.9
387
+
388
+ def test_signal_features_without_stats(self):
389
+ """Signal features should default safely without stats."""
390
+ sys.path.insert(0, str(Path(__file__).parent.parent))
391
+ from feature_extractor import FeatureExtractor
392
+ fe = FeatureExtractor()
393
+ # No set_context call — signal_stats empty
394
+ features = fe.extract_features(
395
+ {'id': 99, 'content': 'test', 'importance': 5},
396
+ 'test'
397
+ )
398
+ assert features[10] == 0.0 # No stats → 0.0
399
+ assert features[11] == 0.5 # No stats → neutral 0.5
@@ -258,7 +258,7 @@ class TestGenerateSyntheticData:
258
258
  assert "label" in r
259
259
  assert "source" in r
260
260
  assert "features" in r
261
- assert len(r["features"]) == 10 # 10-dimensional feature vector
261
+ assert len(r["features"]) == 12 # 12-dimensional feature vector (v2.7.4)
262
262
 
263
263
  def test_labels_in_range(self, bootstrapper_with_data):
264
264
  records = bootstrapper_with_data.generate_synthetic_training_data()
package/ui/index.html CHANGED
@@ -742,6 +742,10 @@
742
742
  </li>
743
743
  </ul>
744
744
 
745
+ <!-- Privacy Notice & Feedback Progress (v2.7.4) -->
746
+ <div id="privacy-notice"></div>
747
+ <div id="feedback-progress" class="mb-3"></div>
748
+
745
749
  <div class="tab-content">
746
750
  <!-- Graph Visualization -->
747
751
  <div class="tab-pane fade show active" id="graph-pane">
@@ -956,6 +960,20 @@
956
960
  </div>
957
961
  </div>
958
962
 
963
+ <!-- What We Learned (v2.7.4 — Summary Card) -->
964
+ <div class="card p-3 mb-3" id="what-we-learned-card">
965
+ <div class="d-flex justify-content-between align-items-center mb-3">
966
+ <h6 class="mb-0"><i class="bi bi-lightbulb"></i> What SuperLocalMemory Learned About You</h6>
967
+ <span class="badge bg-success" id="learned-profile-badge">default</span>
968
+ </div>
969
+ <div id="what-we-learned-content">
970
+ <div class="text-center text-muted py-3">
971
+ <i class="bi bi-hourglass-split" style="font-size: 1.5rem;"></i>
972
+ <p class="mt-2 mb-0 small">Loading learned insights...</p>
973
+ </div>
974
+ </div>
975
+ </div>
976
+
959
977
  <div class="row g-3">
960
978
  <!-- Tech Preferences (Layer 1) -->
961
979
  <div class="col-md-6">
@@ -1239,6 +1257,25 @@
1239
1257
  </div>
1240
1258
  </div>
1241
1259
  </div>
1260
+ <!-- Learning Data Management (v2.7.4) -->
1261
+ <div class="card p-3 mb-3">
1262
+ <h5 class="mb-3"><i class="bi bi-brain"></i> Learning Data</h5>
1263
+ <p class="text-muted small mb-3">
1264
+ SuperLocalMemory learns from your usage patterns to improve recall results.
1265
+ All learning data is stored locally in <code>~/.claude-memory/learning.db</code>.
1266
+ </p>
1267
+ <div id="learning-data-stats" class="mb-3"></div>
1268
+ <div class="d-flex gap-2">
1269
+ <button class="btn btn-outline-danger" onclick="resetLearningData()">
1270
+ <i class="bi bi-arrow-counterclockwise"></i> Reset Learning Data
1271
+ </button>
1272
+ <button class="btn btn-outline-info" onclick="backupLearningDb()">
1273
+ <i class="bi bi-download"></i> Backup Learning DB
1274
+ </button>
1275
+ </div>
1276
+ <small class="text-muted mt-2 d-block">Reset clears all learned preferences, feedback signals, and patterns. Your memories are preserved.</small>
1277
+ </div>
1278
+
1242
1279
  <!-- Backup History -->
1243
1280
  <div class="card p-3">
1244
1281
  <h5 class="mb-3"><i class="bi bi-clock-history"></i> Backup History</h5>
@@ -1319,6 +1356,7 @@
1319
1356
  <script src="static/js/events.js"></script>
1320
1357
  <script src="static/js/agents.js"></script>
1321
1358
  <script src="static/js/learning.js"></script>
1359
+ <script src="static/js/feedback.js"></script>
1322
1360
  <script src="static/js/init.js"></script>
1323
1361
 
1324
1362
  <footer>