superlocalmemory 3.4.10 → 3.4.11
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/README.md +17 -11
- package/docs/skill-evolution.md +77 -10
- package/ide/hooks/tool-event-hook.sh +4 -4
- package/package.json +1 -1
- package/pyproject.toml +3 -2
- package/src/superlocalmemory/cli/commands.py +170 -0
- package/src/superlocalmemory/cli/main.py +21 -0
- package/src/superlocalmemory/cli/setup_wizard.py +54 -11
- package/src/superlocalmemory/core/config.py +35 -0
- package/src/superlocalmemory/core/consolidation_engine.py +128 -0
- package/src/superlocalmemory/core/embedding_worker.py +1 -1
- package/src/superlocalmemory/core/engine.py +12 -0
- package/src/superlocalmemory/core/fact_consolidator.py +425 -0
- package/src/superlocalmemory/core/graph_pruner.py +290 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +20 -0
- package/src/superlocalmemory/core/recall_pipeline.py +9 -0
- package/src/superlocalmemory/core/tier_manager.py +325 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +6 -5
- package/src/superlocalmemory/evolution/__init__.py +29 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +115 -0
- package/src/superlocalmemory/evolution/evolution_store.py +302 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +181 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +555 -0
- package/src/superlocalmemory/evolution/triggers.py +367 -0
- package/src/superlocalmemory/evolution/types.py +92 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +13 -0
- package/src/superlocalmemory/learning/skill_performance_miner.py +44 -11
- package/src/superlocalmemory/mcp/server.py +4 -0
- package/src/superlocalmemory/mcp/tools_evolution.py +338 -0
- package/src/superlocalmemory/retrieval/engine.py +64 -4
- package/src/superlocalmemory/retrieval/forgetting_filter.py +22 -7
- package/src/superlocalmemory/retrieval/strategy.py +2 -2
- package/src/superlocalmemory/server/routes/behavioral.py +19 -15
- package/src/superlocalmemory/server/routes/evolution.py +213 -0
- package/src/superlocalmemory/server/routes/tiers.py +195 -0
- package/src/superlocalmemory/server/unified_daemon.py +36 -5
- package/src/superlocalmemory/storage/schema_v3411.py +149 -0
- package/src/superlocalmemory/ui/index.html +5 -2
- package/src/superlocalmemory/ui/js/lifecycle.js +83 -0
- package/src/superlocalmemory/ui/js/ng-skills.js +394 -10
- package/src/superlocalmemory.egg-info/PKG-INFO +609 -0
- package/src/superlocalmemory.egg-info/SOURCES.txt +335 -0
- package/src/superlocalmemory.egg-info/dependency_links.txt +1 -0
- package/src/superlocalmemory.egg-info/entry_points.txt +2 -0
- package/src/superlocalmemory.egg-info/requires.txt +55 -0
- package/src/superlocalmemory.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""SLM v3.4.11 "Skill Evolution" — Evolution MCP Tools.
|
|
6
|
+
|
|
7
|
+
Three evolution tools:
|
|
8
|
+
- evolve_skill: Manually trigger evolution for a specific skill
|
|
9
|
+
- skill_health: Get health metrics for a skill or all skills
|
|
10
|
+
- skill_lineage: Get evolution lineage for a skill
|
|
11
|
+
|
|
12
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import sqlite3
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Callable
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
MEMORY_DB = Path.home() / ".superlocalmemory" / "memory.db"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register_evolution_tools(server, get_engine: Callable) -> None:
|
|
30
|
+
"""Register evolution MCP tools for skill evolution intelligence."""
|
|
31
|
+
|
|
32
|
+
@server.tool()
|
|
33
|
+
async def evolve_skill(
|
|
34
|
+
skill_name: str,
|
|
35
|
+
evolution_type: str = "fix",
|
|
36
|
+
reason: str = "",
|
|
37
|
+
) -> dict:
|
|
38
|
+
"""Manually trigger evolution for a specific skill.
|
|
39
|
+
|
|
40
|
+
Runs the full evolution pipeline: screen -> confirm -> mutate ->
|
|
41
|
+
blind verify -> persist. Requires evolution to be enabled in config.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
skill_name: Name of the skill to evolve (e.g. "brainstorming")
|
|
45
|
+
evolution_type: One of 'fix', 'derived', 'captured'
|
|
46
|
+
reason: Optional reason / evidence for the evolution
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
# Check if evolution is enabled in config
|
|
50
|
+
config_path = Path.home() / ".superlocalmemory" / "config.json"
|
|
51
|
+
evo_cfg = {}
|
|
52
|
+
if config_path.exists():
|
|
53
|
+
with open(config_path) as f:
|
|
54
|
+
cfg = json.load(f)
|
|
55
|
+
evo_cfg = cfg.get("evolution", {})
|
|
56
|
+
|
|
57
|
+
if not evo_cfg.get("enabled", False):
|
|
58
|
+
return {
|
|
59
|
+
"success": False,
|
|
60
|
+
"error": "Evolution is disabled. Enable via: slm config set evolution.enabled true",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
from superlocalmemory.evolution.skill_evolver import SkillEvolver
|
|
64
|
+
from superlocalmemory.evolution.types import (
|
|
65
|
+
EvolutionCandidate,
|
|
66
|
+
EvolutionType,
|
|
67
|
+
TriggerType,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Map string to enum
|
|
71
|
+
type_map = {"fix": EvolutionType.FIX, "derived": EvolutionType.DERIVED, "captured": EvolutionType.CAPTURED}
|
|
72
|
+
evo_type = type_map.get(evolution_type, EvolutionType.FIX)
|
|
73
|
+
|
|
74
|
+
# Build a minimal config for the evolver
|
|
75
|
+
class _EvoCfg:
|
|
76
|
+
enabled = True
|
|
77
|
+
backend = evo_cfg.get("backend", "auto")
|
|
78
|
+
max_evolutions_per_cycle = evo_cfg.get("max_evolutions_per_cycle", 3)
|
|
79
|
+
|
|
80
|
+
class _Cfg:
|
|
81
|
+
evolution = _EvoCfg()
|
|
82
|
+
|
|
83
|
+
db_path = str(MEMORY_DB)
|
|
84
|
+
evolver = SkillEvolver(db_path, _Cfg())
|
|
85
|
+
|
|
86
|
+
# Build candidate from manual trigger
|
|
87
|
+
evidence = (reason,) if reason else ("Manual evolution trigger via MCP",)
|
|
88
|
+
candidate = EvolutionCandidate(
|
|
89
|
+
skill_name=skill_name,
|
|
90
|
+
evolution_type=evo_type,
|
|
91
|
+
trigger=TriggerType.HEALTH_CHECK,
|
|
92
|
+
evidence=evidence,
|
|
93
|
+
effective_score=0.0,
|
|
94
|
+
invocation_count=0,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Process through the pipeline
|
|
98
|
+
engine = get_engine()
|
|
99
|
+
profile_id = engine.profile_id if engine else "default"
|
|
100
|
+
|
|
101
|
+
evolver._store.reset_cycle()
|
|
102
|
+
outcome = evolver._process_candidate(candidate, profile_id)
|
|
103
|
+
|
|
104
|
+
# Fetch the latest record for this skill to return details
|
|
105
|
+
recent = evolver._store.get_skill_history(skill_name, limit=1)
|
|
106
|
+
record_info = {}
|
|
107
|
+
if recent:
|
|
108
|
+
r = recent[0]
|
|
109
|
+
record_info = {
|
|
110
|
+
"id": r.id,
|
|
111
|
+
"status": r.status.value,
|
|
112
|
+
"mutation_summary": r.mutation_summary,
|
|
113
|
+
"blind_verified": r.blind_verified,
|
|
114
|
+
"rejection_reason": r.rejection_reason,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"success": outcome == "evolved",
|
|
119
|
+
"outcome": outcome,
|
|
120
|
+
"skill_name": skill_name,
|
|
121
|
+
"evolution_type": evolution_type,
|
|
122
|
+
**record_info,
|
|
123
|
+
}
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
logger.debug("evolve_skill failed: %s", exc)
|
|
126
|
+
return {"success": False, "error": str(exc)}
|
|
127
|
+
|
|
128
|
+
@server.tool()
|
|
129
|
+
async def skill_health(
|
|
130
|
+
skill_name: str = "",
|
|
131
|
+
include_history: bool = False,
|
|
132
|
+
) -> dict:
|
|
133
|
+
"""Get health metrics for a skill or all skills.
|
|
134
|
+
|
|
135
|
+
Queries behavioral assertions (skill_performance category) and
|
|
136
|
+
tool_events to compute per-skill invocation counts, effective
|
|
137
|
+
rates, and status.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
skill_name: Specific skill name (empty = all skills)
|
|
141
|
+
include_history: Include recent tool event history per skill
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
engine = get_engine()
|
|
145
|
+
profile_id = engine.profile_id if engine else "default"
|
|
146
|
+
db_path = str(MEMORY_DB)
|
|
147
|
+
|
|
148
|
+
conn = sqlite3.connect(db_path, timeout=10)
|
|
149
|
+
conn.row_factory = sqlite3.Row
|
|
150
|
+
|
|
151
|
+
# Gather per-skill invocation stats from tool_events
|
|
152
|
+
# Skills are logged as tool_name='Skill' with actual skill name in input_summary
|
|
153
|
+
if skill_name:
|
|
154
|
+
# M-LIKE: Escape LIKE wildcards in user-provided skill_name
|
|
155
|
+
safe_name = skill_name.replace('\\', '\\\\').replace('%', r'\%').replace('_', r'\_')
|
|
156
|
+
event_query = (
|
|
157
|
+
"SELECT input_summary, event_type, created_at, duration_ms "
|
|
158
|
+
"FROM tool_events "
|
|
159
|
+
"WHERE profile_id = ? AND tool_name = 'Skill' "
|
|
160
|
+
"AND input_summary LIKE ? ESCAPE '\\' "
|
|
161
|
+
"ORDER BY created_at DESC"
|
|
162
|
+
)
|
|
163
|
+
event_rows = conn.execute(event_query, (profile_id, f"%{safe_name}%")).fetchall()
|
|
164
|
+
# Aggregate
|
|
165
|
+
invocations = len(event_rows)
|
|
166
|
+
errors = sum(1 for r in event_rows if dict(r).get("event_type") == "error")
|
|
167
|
+
last_invoked = dict(event_rows[0]).get("created_at", "") if event_rows else ""
|
|
168
|
+
effective_rate = ((invocations - errors) / invocations) if invocations > 0 else 0.0
|
|
169
|
+
skill_entries = [{
|
|
170
|
+
"name": skill_name,
|
|
171
|
+
"invocations": invocations,
|
|
172
|
+
"errors": errors,
|
|
173
|
+
"effective_rate": round(effective_rate, 4),
|
|
174
|
+
"last_invoked": last_invoked,
|
|
175
|
+
"status": "healthy" if effective_rate >= 0.7 else ("degraded" if effective_rate >= 0.4 else "critical"),
|
|
176
|
+
}]
|
|
177
|
+
if include_history:
|
|
178
|
+
skill_entries[0]["recent_events"] = [
|
|
179
|
+
dict(r) for r in event_rows[:10]
|
|
180
|
+
]
|
|
181
|
+
else:
|
|
182
|
+
# Get all Skill tool events and extract skill names from input_summary
|
|
183
|
+
event_query = (
|
|
184
|
+
"SELECT input_summary, event_type, created_at "
|
|
185
|
+
"FROM tool_events "
|
|
186
|
+
"WHERE profile_id = ? AND tool_name = 'Skill' "
|
|
187
|
+
"ORDER BY created_at DESC LIMIT 500"
|
|
188
|
+
)
|
|
189
|
+
event_rows = conn.execute(event_query, (profile_id,)).fetchall()
|
|
190
|
+
|
|
191
|
+
# Parse skill names from input_summary and aggregate
|
|
192
|
+
from collections import defaultdict
|
|
193
|
+
skill_stats: dict = defaultdict(lambda: {"invocations": 0, "errors": 0, "last_invoked": ""})
|
|
194
|
+
for row in event_rows:
|
|
195
|
+
r = dict(row)
|
|
196
|
+
summary = r.get("input_summary", "")
|
|
197
|
+
# Extract skill name from JSON or plain text
|
|
198
|
+
sname = ""
|
|
199
|
+
try:
|
|
200
|
+
parsed = json.loads(summary)
|
|
201
|
+
sname = parsed.get("skill", "") or parsed.get("name", "")
|
|
202
|
+
except (json.JSONDecodeError, TypeError):
|
|
203
|
+
if ":" in summary:
|
|
204
|
+
sname = summary.split('"')[1] if '"' in summary else summary.strip()
|
|
205
|
+
if not sname:
|
|
206
|
+
continue
|
|
207
|
+
stats = skill_stats[sname]
|
|
208
|
+
stats["invocations"] += 1
|
|
209
|
+
if r.get("event_type") == "error":
|
|
210
|
+
stats["errors"] += 1
|
|
211
|
+
if not stats["last_invoked"]:
|
|
212
|
+
stats["last_invoked"] = r.get("created_at", "")
|
|
213
|
+
|
|
214
|
+
skill_entries = []
|
|
215
|
+
for sname, stats in sorted(skill_stats.items(), key=lambda x: x[1]["invocations"], reverse=True)[:50]:
|
|
216
|
+
inv = stats["invocations"]
|
|
217
|
+
errs = stats["errors"]
|
|
218
|
+
eff = ((inv - errs) / inv) if inv > 0 else 0.0
|
|
219
|
+
skill_entries.append({
|
|
220
|
+
"name": sname,
|
|
221
|
+
"invocations": inv,
|
|
222
|
+
"errors": errs,
|
|
223
|
+
"effective_rate": round(eff, 4),
|
|
224
|
+
"last_invoked": stats["last_invoked"],
|
|
225
|
+
"status": "healthy" if eff >= 0.7 else ("degraded" if eff >= 0.4 else "critical"),
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
# Gather skill_performance assertions
|
|
229
|
+
assertion_query = (
|
|
230
|
+
"SELECT trigger_condition, action, confidence "
|
|
231
|
+
"FROM behavioral_assertions "
|
|
232
|
+
"WHERE profile_id = ? AND category = 'skill_performance'"
|
|
233
|
+
)
|
|
234
|
+
assertion_params = [profile_id]
|
|
235
|
+
if skill_name:
|
|
236
|
+
safe_assert_name = skill_name.replace('\\', '\\\\').replace('%', r'\%').replace('_', r'\_')
|
|
237
|
+
assertion_query += " AND trigger_condition LIKE ? ESCAPE '\\'"
|
|
238
|
+
assertion_params.append(f"%{safe_assert_name}%")
|
|
239
|
+
assertion_rows = conn.execute(assertion_query, tuple(assertion_params)).fetchall()
|
|
240
|
+
|
|
241
|
+
skills = skill_entries
|
|
242
|
+
|
|
243
|
+
# Add assertion insights
|
|
244
|
+
assertion_insights = [
|
|
245
|
+
{"trigger": dict(a)["trigger_condition"], "action": dict(a)["action"], "confidence": dict(a)["confidence"]}
|
|
246
|
+
for a in assertion_rows
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
conn.close()
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
"skills": skills,
|
|
253
|
+
"skill_count": len(skills),
|
|
254
|
+
"assertion_insights": assertion_insights,
|
|
255
|
+
"profile_id": profile_id,
|
|
256
|
+
}
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
logger.debug("skill_health failed: %s", exc)
|
|
259
|
+
return {"skills": [], "skill_count": 0, "error": str(exc)}
|
|
260
|
+
|
|
261
|
+
@server.tool()
|
|
262
|
+
async def skill_lineage(
|
|
263
|
+
skill_name: str = "",
|
|
264
|
+
) -> dict:
|
|
265
|
+
"""Get evolution lineage for a skill.
|
|
266
|
+
|
|
267
|
+
Queries the skill_evolution_log table and builds a version tree
|
|
268
|
+
showing how skills evolved from their parents.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
skill_name: Specific skill name (empty = all skills)
|
|
272
|
+
"""
|
|
273
|
+
try:
|
|
274
|
+
db_path = str(MEMORY_DB)
|
|
275
|
+
conn = sqlite3.connect(db_path, timeout=10)
|
|
276
|
+
conn.row_factory = sqlite3.Row
|
|
277
|
+
|
|
278
|
+
if skill_name:
|
|
279
|
+
rows = conn.execute(
|
|
280
|
+
"SELECT id, skill_name, parent_skill_id, evolution_type, "
|
|
281
|
+
"trigger_type, generation, status, mutation_summary, "
|
|
282
|
+
"blind_verified, created_at, completed_at "
|
|
283
|
+
"FROM skill_evolution_log "
|
|
284
|
+
"WHERE skill_name = ? OR parent_skill_id = ? "
|
|
285
|
+
"ORDER BY created_at ASC",
|
|
286
|
+
(skill_name, skill_name),
|
|
287
|
+
).fetchall()
|
|
288
|
+
else:
|
|
289
|
+
rows = conn.execute(
|
|
290
|
+
"SELECT id, skill_name, parent_skill_id, evolution_type, "
|
|
291
|
+
"trigger_type, generation, status, mutation_summary, "
|
|
292
|
+
"blind_verified, created_at, completed_at "
|
|
293
|
+
"FROM skill_evolution_log "
|
|
294
|
+
"ORDER BY created_at DESC LIMIT 100",
|
|
295
|
+
).fetchall()
|
|
296
|
+
|
|
297
|
+
conn.close()
|
|
298
|
+
|
|
299
|
+
lineage = [
|
|
300
|
+
{
|
|
301
|
+
"id": dict(r)["id"],
|
|
302
|
+
"skill_name": dict(r)["skill_name"],
|
|
303
|
+
"parent_skill_id": dict(r).get("parent_skill_id", ""),
|
|
304
|
+
"evolution_type": dict(r)["evolution_type"],
|
|
305
|
+
"trigger": dict(r)["trigger_type"],
|
|
306
|
+
"generation": dict(r).get("generation", 0),
|
|
307
|
+
"status": dict(r)["status"],
|
|
308
|
+
"mutation_summary": dict(r).get("mutation_summary", ""),
|
|
309
|
+
"blind_verified": bool(dict(r).get("blind_verified", 0)),
|
|
310
|
+
"created_at": dict(r).get("created_at", ""),
|
|
311
|
+
"completed_at": dict(r).get("completed_at", ""),
|
|
312
|
+
}
|
|
313
|
+
for r in rows
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
# Build tree structure: group by root skill
|
|
317
|
+
tree: dict = {}
|
|
318
|
+
for entry in lineage:
|
|
319
|
+
root = entry.get("parent_skill_id") or entry["skill_name"]
|
|
320
|
+
if root not in tree:
|
|
321
|
+
tree[root] = {"root": root, "evolutions": []}
|
|
322
|
+
tree[root]["evolutions"].append({
|
|
323
|
+
"id": entry["id"],
|
|
324
|
+
"skill_name": entry["skill_name"],
|
|
325
|
+
"evolution_type": entry["evolution_type"],
|
|
326
|
+
"status": entry["status"],
|
|
327
|
+
"generation": entry["generation"],
|
|
328
|
+
"created_at": entry["created_at"],
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
"lineage": lineage,
|
|
333
|
+
"lineage_count": len(lineage),
|
|
334
|
+
"tree": tree,
|
|
335
|
+
}
|
|
336
|
+
except Exception as exc:
|
|
337
|
+
logger.debug("skill_lineage failed: %s", exc)
|
|
338
|
+
return {"lineage": [], "lineage_count": 0, "tree": {}, "error": str(exc)}
|
|
@@ -193,9 +193,6 @@ class RetrievalEngine:
|
|
|
193
193
|
facts = self._load_facts(top, profile_id)
|
|
194
194
|
|
|
195
195
|
# V3.3.21: Session diversity for aggregation queries.
|
|
196
|
-
# Cat 1 (single-hop/aggregation) needs facts from MULTIPLE sessions.
|
|
197
|
-
# Without diversity enforcement, top-20 may all come from 1-2 sessions,
|
|
198
|
-
# missing scattered mentions across 19+ sessions.
|
|
199
196
|
if strat.query_type == "aggregation" and facts:
|
|
200
197
|
top = self._enforce_session_diversity(top, facts, min_sessions=3, top_k=20)
|
|
201
198
|
|
|
@@ -212,8 +209,18 @@ class RetrievalEngine:
|
|
|
212
209
|
ce_alpha = 0.5 if strat.query_type in ("multi_hop", "temporal") else 0.75
|
|
213
210
|
top = self._apply_reranker(query, top, facts, alpha=ce_alpha)
|
|
214
211
|
|
|
212
|
+
# V3.4.11: Channel diversity — guarantee entity_graph results appear in
|
|
213
|
+
# the final output. Applied AFTER reranker so results can't be pushed out.
|
|
214
|
+
final_top = top[:effective_limit]
|
|
215
|
+
final_top = self._enforce_channel_diversity(
|
|
216
|
+
final_top, fused, ch_results, effective_limit,
|
|
217
|
+
)
|
|
218
|
+
# Reload facts for any newly injected results
|
|
219
|
+
if len(final_top) > len(top[:effective_limit]):
|
|
220
|
+
facts = self._load_facts(final_top, profile_id)
|
|
221
|
+
|
|
215
222
|
# 6. Build response
|
|
216
|
-
results = self._build_results(
|
|
223
|
+
results = self._build_results(final_top, facts, strat)
|
|
217
224
|
ms = (time.monotonic() - t0) * 1000.0
|
|
218
225
|
return RecallResponse(
|
|
219
226
|
query=query, mode=mode, results=results,
|
|
@@ -334,6 +341,54 @@ class RetrievalEngine:
|
|
|
334
341
|
remaining = [fr for fr in rest if fr.fact_id not in promoted_ids]
|
|
335
342
|
return top + promoted + remaining
|
|
336
343
|
|
|
344
|
+
# -- Channel diversity enforcement ----------------------------------------
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def _enforce_channel_diversity(
|
|
348
|
+
top: list,
|
|
349
|
+
fused: list,
|
|
350
|
+
ch_results: dict[str, list[tuple[str, float]]],
|
|
351
|
+
effective_limit: int,
|
|
352
|
+
min_per_channel: int = 2,
|
|
353
|
+
) -> list:
|
|
354
|
+
"""Ensure structure channels (entity_graph) get representation.
|
|
355
|
+
|
|
356
|
+
V3.4.11: entity_graph finds valid results but RRF scores them low
|
|
357
|
+
because they don't overlap with semantic/bm25 results. This interleaves
|
|
358
|
+
top entity_graph facts into positions 3-4 of the final output instead
|
|
359
|
+
of appending at the end where they'd never be seen.
|
|
360
|
+
"""
|
|
361
|
+
structure_channels = ["entity_graph"]
|
|
362
|
+
top_ids = {fr.fact_id for fr in top}
|
|
363
|
+
|
|
364
|
+
promoted = []
|
|
365
|
+
for ch_name in structure_channels:
|
|
366
|
+
ch_items = ch_results.get(ch_name, [])
|
|
367
|
+
if not ch_items:
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
present = sum(1 for fid, _ in ch_items if fid in top_ids)
|
|
371
|
+
if present >= min_per_channel:
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
needed = min_per_channel - present
|
|
375
|
+
ch_fids = {fid for fid, _ in ch_items}
|
|
376
|
+
for fr in fused:
|
|
377
|
+
if fr.fact_id in ch_fids and fr.fact_id not in top_ids:
|
|
378
|
+
promoted.append(fr)
|
|
379
|
+
top_ids.add(fr.fact_id)
|
|
380
|
+
needed -= 1
|
|
381
|
+
if needed <= 0:
|
|
382
|
+
break
|
|
383
|
+
|
|
384
|
+
if not promoted:
|
|
385
|
+
return top
|
|
386
|
+
|
|
387
|
+
# Append as safety net — with proper RRF weights (strategy.py),
|
|
388
|
+
# entity_graph facts should already rank naturally in the top-k.
|
|
389
|
+
# This only fires when they're still missing despite weight boost.
|
|
390
|
+
return list(top) + promoted
|
|
391
|
+
|
|
337
392
|
# -- Channel execution --------------------------------------------------
|
|
338
393
|
|
|
339
394
|
def _embed_query(self, query: str) -> list[float] | None:
|
|
@@ -369,6 +424,11 @@ class RetrievalEngine:
|
|
|
369
424
|
if needs_embedding:
|
|
370
425
|
try:
|
|
371
426
|
q_emb = self._embed_query(query)
|
|
427
|
+
if q_emb is None:
|
|
428
|
+
logger.warning(
|
|
429
|
+
"Query embedding returned None — semantic, hopfield, "
|
|
430
|
+
"spreading_activation channels will be skipped this recall"
|
|
431
|
+
)
|
|
372
432
|
except Exception as exc:
|
|
373
433
|
logger.warning("Query embedding failed: %s", exc)
|
|
374
434
|
|
|
@@ -41,9 +41,21 @@ _ZONE_WEIGHTS: dict[str, float] = {
|
|
|
41
41
|
"forgotten": 0.0,
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
#
|
|
44
|
+
# V3.4.11: Deep recall weights — includes cold/archive with reduced scores
|
|
45
|
+
_DEEP_ZONE_WEIGHTS: dict[str, float] = {
|
|
46
|
+
"active": 1.0,
|
|
47
|
+
"warm": 0.7,
|
|
48
|
+
"cold": 0.3,
|
|
49
|
+
"archive": 0.15,
|
|
50
|
+
"forgotten": 0.05,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Zones where facts are excluded from results (default recall)
|
|
45
54
|
_EXCLUDED_ZONES: frozenset[str] = frozenset({"archive", "forgotten"})
|
|
46
55
|
|
|
56
|
+
# Deep recall excludes nothing — every fact is searchable
|
|
57
|
+
_DEEP_EXCLUDED_ZONES: frozenset[str] = frozenset()
|
|
58
|
+
|
|
47
59
|
|
|
48
60
|
class ForgettingFilter:
|
|
49
61
|
"""Post-retrieval filter that applies Ebbinghaus retention weighting.
|
|
@@ -51,11 +63,12 @@ class ForgettingFilter:
|
|
|
51
63
|
Removes archived/forgotten facts and adjusts scores for other zones.
|
|
52
64
|
"""
|
|
53
65
|
|
|
54
|
-
__slots__ = ("_db", "_config")
|
|
66
|
+
__slots__ = ("_db", "_config", "_deep_recall")
|
|
55
67
|
|
|
56
|
-
def __init__(self, db: DatabaseManager, config: ForgettingConfig) -> None:
|
|
68
|
+
def __init__(self, db: DatabaseManager, config: ForgettingConfig, deep_recall: bool = False) -> None:
|
|
57
69
|
self._db = db
|
|
58
70
|
self._config = config
|
|
71
|
+
self._deep_recall = deep_recall
|
|
59
72
|
|
|
60
73
|
def filter(
|
|
61
74
|
self,
|
|
@@ -112,12 +125,14 @@ class ForgettingFilter:
|
|
|
112
125
|
|
|
113
126
|
zone = ret_data.get("lifecycle_zone", "active")
|
|
114
127
|
|
|
115
|
-
|
|
116
|
-
|
|
128
|
+
# V3.4.11: Deep recall mode includes all tiers
|
|
129
|
+
excluded = _DEEP_EXCLUDED_ZONES if self._deep_recall else _EXCLUDED_ZONES
|
|
130
|
+
weights = _DEEP_ZONE_WEIGHTS if self._deep_recall else _ZONE_WEIGHTS
|
|
131
|
+
|
|
132
|
+
if zone in excluded:
|
|
117
133
|
continue
|
|
118
134
|
|
|
119
|
-
|
|
120
|
-
weight = _ZONE_WEIGHTS.get(zone, 1.0)
|
|
135
|
+
weight = weights.get(zone, 1.0)
|
|
121
136
|
new_results.append((fact_id, score * weight))
|
|
122
137
|
|
|
123
138
|
filtered[channel_name] = new_results
|
|
@@ -17,11 +17,11 @@ from dataclasses import dataclass, field
|
|
|
17
17
|
|
|
18
18
|
STRATEGY_PRESETS: dict[str, dict[str, float]] = {
|
|
19
19
|
"temporal": {"semantic": 0.8, "bm25": 1.5, "entity_graph": 0.8, "temporal": 2.0, "spreading_activation": 0.5, "hopfield": 0.5},
|
|
20
|
-
"multi_hop": {"semantic": 1.0, "bm25": 0.8, "entity_graph": 2.
|
|
20
|
+
"multi_hop": {"semantic": 1.0, "bm25": 0.8, "entity_graph": 2.5, "temporal": 0.5, "spreading_activation": 2.0, "hopfield": 0.7},
|
|
21
21
|
"aggregation": {"semantic": 1.2, "bm25": 1.5, "entity_graph": 1.0, "temporal": 0.5, "spreading_activation": 0.8, "hopfield": 0.6},
|
|
22
22
|
"opinion": {"semantic": 1.8, "bm25": 0.6, "entity_graph": 0.8, "temporal": 0.3, "spreading_activation": 0.5, "hopfield": 0.5},
|
|
23
23
|
"factual": {"semantic": 1.2, "bm25": 1.4, "entity_graph": 1.0, "temporal": 0.6, "spreading_activation": 0.8, "hopfield": 0.8},
|
|
24
|
-
"entity": {"semantic": 1.0, "bm25": 1.
|
|
24
|
+
"entity": {"semantic": 1.0, "bm25": 1.2, "entity_graph": 3.0, "temporal": 0.5, "spreading_activation": 1.5, "hopfield": 0.9},
|
|
25
25
|
"general": {},
|
|
26
26
|
"vague": {"semantic": 0.8, "bm25": 0.5, "entity_graph": 0.6, "temporal": 0.3, "spreading_activation": 1.5, "hopfield": 1.1},
|
|
27
27
|
}
|
|
@@ -193,6 +193,7 @@ async def get_tool_events(tool_name: str = "", limit: int = 100):
|
|
|
193
193
|
try:
|
|
194
194
|
import sqlite3 as _sqlite3
|
|
195
195
|
profile = get_active_profile()
|
|
196
|
+
limit = min(int(limit), 1000)
|
|
196
197
|
conn = _sqlite3.connect(str(MEMORY_DIR / "memory.db"))
|
|
197
198
|
conn.row_factory = _sqlite3.Row
|
|
198
199
|
|
|
@@ -208,11 +209,12 @@ async def get_tool_events(tool_name: str = "", limit: int = 100):
|
|
|
208
209
|
query += " ORDER BY created_at DESC LIMIT ?"
|
|
209
210
|
params.append(limit)
|
|
210
211
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
212
|
+
try:
|
|
213
|
+
rows = conn.execute(query, tuple(params)).fetchall()
|
|
214
|
+
events = [dict(r) for r in rows]
|
|
215
|
+
return {"events": events, "count": len(events)}
|
|
216
|
+
finally:
|
|
217
|
+
conn.close()
|
|
216
218
|
except Exception as e:
|
|
217
219
|
logger.debug("get_tool_events error: %s", e)
|
|
218
220
|
return {"events": [], "count": 0, "error": str(e)}
|
|
@@ -276,16 +278,18 @@ async def log_tool_event_api(data: dict):
|
|
|
276
278
|
output_summary = str(output_summary)[:500] if output_summary else ""
|
|
277
279
|
|
|
278
280
|
conn = _sqlite3.connect(str(MEMORY_DIR / "memory.db"))
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
281
|
+
try:
|
|
282
|
+
conn.execute(
|
|
283
|
+
"INSERT INTO tool_events "
|
|
284
|
+
"(session_id, profile_id, project_path, tool_name, event_type, "
|
|
285
|
+
" input_summary, output_summary, duration_ms, metadata, created_at) "
|
|
286
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, 0, '{}', ?)",
|
|
287
|
+
(session_id, profile, project_path, tool_name, event_type,
|
|
288
|
+
input_summary, output_summary, now),
|
|
289
|
+
)
|
|
290
|
+
conn.commit()
|
|
291
|
+
finally:
|
|
292
|
+
conn.close()
|
|
289
293
|
return {"ok": True}
|
|
290
294
|
except Exception as e:
|
|
291
295
|
return {"ok": False, "error": str(e)}
|