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.
- package/CHANGELOG.md +17 -0
- package/README.md +1 -1
- package/hooks/post-recall-hook.js +53 -0
- package/mcp_server.py +348 -17
- package/package.json +2 -1
- package/skills/slm-recall/SKILL.md +1 -0
- package/src/auto_backup.py +64 -31
- package/src/learning/adaptive_ranker.py +70 -1
- package/src/learning/feature_extractor.py +71 -17
- package/src/learning/feedback_collector.py +114 -0
- package/src/learning/learning_db.py +158 -34
- package/src/learning/tests/test_adaptive_ranker.py +5 -4
- package/src/learning/tests/test_aggregator.py +4 -3
- package/src/learning/tests/test_feedback_collector.py +7 -4
- package/src/learning/tests/test_signal_inference.py +399 -0
- package/src/learning/tests/test_synthetic_bootstrap.py +1 -1
- package/ui/index.html +38 -0
- package/ui/js/feedback.js +333 -0
- package/ui/js/learning.js +117 -0
- package/ui/js/modal.js +22 -1
- package/ui/js/profiles.js +8 -0
- package/ui/js/settings.js +58 -1
|
@@ -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"]) ==
|
|
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>
|