superlocalmemory 4.0.5 → 4.0.7
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 +108 -0
- package/README.md +8 -9
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +106 -0
- package/src/superlocalmemory/brain/truth.py +80 -10
- package/src/superlocalmemory/cli/__main__.py +17 -0
- package/src/superlocalmemory/cli/commands.py +28 -3
- package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
- package/src/superlocalmemory/cli/gdpr_io.py +109 -0
- package/src/superlocalmemory/cli/main.py +85 -0
- package/src/superlocalmemory/cli/summary_cmd.py +195 -0
- package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +26 -0
- package/src/superlocalmemory/code_graph/bridge/event_listeners.py +14 -3
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +206 -0
- package/src/superlocalmemory/code_graph/config.py +65 -1
- package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
- package/src/superlocalmemory/code_graph/graph_store.py +180 -3
- package/src/superlocalmemory/code_graph/parser.py +280 -100
- package/src/superlocalmemory/compliance/gdpr.py +358 -0
- package/src/superlocalmemory/core/config.py +44 -1
- package/src/superlocalmemory/core/engine_wiring.py +5 -1
- package/src/superlocalmemory/core/fact_consolidator.py +24 -1
- package/src/superlocalmemory/core/maintenance.py +93 -1
- package/src/superlocalmemory/core/recall_worker.py +33 -12
- package/src/superlocalmemory/infra/backup.py +138 -0
- package/src/superlocalmemory/infra/backup_obligations.py +423 -0
- package/src/superlocalmemory/learning/engagement.py +165 -0
- package/src/superlocalmemory/mcp/tools_code_graph.py +78 -7
- package/src/superlocalmemory/mcp/tools_v3.py +20 -6
- package/src/superlocalmemory/retrieval/engine.py +21 -0
- package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
- package/src/superlocalmemory/server/routes/brain.py +283 -15
- package/src/superlocalmemory/server/routes/learning.py +13 -25
- package/src/superlocalmemory/server/routes/memories.py +61 -0
- package/src/superlocalmemory/server/routes/v3_api.py +171 -60
- package/src/superlocalmemory/storage/database.py +36 -0
- package/src/superlocalmemory/storage/models.py +12 -4
- package/src/superlocalmemory/storage/schema_code_graph.py +44 -1
- package/src/superlocalmemory/summaries/__init__.py +37 -0
- package/src/superlocalmemory/summaries/base.py +108 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
- package/src/superlocalmemory/summaries/project_work_log.py +424 -0
- package/src/superlocalmemory/summaries/session_summary.py +307 -0
- package/src/superlocalmemory/ui/css/design-system.css +76 -1
- package/src/superlocalmemory/ui/index.html +29 -12
- package/src/superlocalmemory/ui/js/fact-detail.js +61 -0
- package/src/superlocalmemory/ui/js/od-agents.js +49 -5
- package/src/superlocalmemory/ui/js/od-brain.js +257 -77
- package/src/superlocalmemory/ui/js/od-graph.js +147 -6
|
@@ -320,3 +320,168 @@ class EngagementTracker:
|
|
|
320
320
|
if raw <= 0:
|
|
321
321
|
return 0.0
|
|
322
322
|
return raw / (raw + 20.0)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------------------------------
|
|
326
|
+
# Derive-on-read engagement — zero hot-path cost (Invariants I1, I3)
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
def derive_engagement_from_dbs(
|
|
330
|
+
memory_db_path: "Path | str",
|
|
331
|
+
learning_db_path: "Path | str",
|
|
332
|
+
profile_id: str,
|
|
333
|
+
) -> "Dict[str, Any]":
|
|
334
|
+
"""Derive engagement metrics from tables that already exist — no writes.
|
|
335
|
+
|
|
336
|
+
Source tables
|
|
337
|
+
-------------
|
|
338
|
+
memory.db : atomic_facts
|
|
339
|
+
store_count — live facts (lifecycle in active/warm/cold)
|
|
340
|
+
days_active — distinct calendar days with at least one live fact
|
|
341
|
+
recent_7d — facts created in the last 7 days (drives health)
|
|
342
|
+
learning.db : learning_signals
|
|
343
|
+
recall_count — COUNT(DISTINCT query) for the profile.
|
|
344
|
+
This is a proxy: one distinct query = one recall
|
|
345
|
+
session. If the same query was run N times, it
|
|
346
|
+
counts as 1. The table is populated by the
|
|
347
|
+
post_tool_outcome_hook when Claude Code surfaces
|
|
348
|
+
recall results; it may be empty in environments
|
|
349
|
+
without that hook, in which case recall_count = 0
|
|
350
|
+
while store_count still reflects real activity.
|
|
351
|
+
|
|
352
|
+
Invariant compliance
|
|
353
|
+
--------------------
|
|
354
|
+
I1 — zero writes, no lock acquisition on the recall/remember hot path.
|
|
355
|
+
I3 — no new table or row is ever created; bounded by the existing
|
|
356
|
+
lifecycle-management and retention systems (atomic_facts rows age
|
|
357
|
+
through active → warm → cold → archived via Langevin dynamics;
|
|
358
|
+
learning_signals rows are pruned by the retention sweep).
|
|
359
|
+
I6 — every field carries a named source.
|
|
360
|
+
|
|
361
|
+
health_status (plain language, non-technical)
|
|
362
|
+
----------------------------------------------
|
|
363
|
+
"ACTIVE" — 10 or more memories added in the last 7 days
|
|
364
|
+
"WARM" — 3–9 memories added in the last 7 days
|
|
365
|
+
"COLD" — 1–2 memories added in the last 7 days
|
|
366
|
+
"INACTIVE" — no new memories in the last 7 days
|
|
367
|
+
"""
|
|
368
|
+
memory_db_path = Path(str(memory_db_path))
|
|
369
|
+
learning_db_path = Path(str(learning_db_path))
|
|
370
|
+
|
|
371
|
+
store_count: int = 0
|
|
372
|
+
days_active: int = 0
|
|
373
|
+
recent_7d: int = 0
|
|
374
|
+
recall_count: int = 0
|
|
375
|
+
|
|
376
|
+
# ── memory.db : atomic_facts ──────────────────────────────────────────
|
|
377
|
+
if memory_db_path.exists():
|
|
378
|
+
try:
|
|
379
|
+
# read-only URI; never creates the file, never acquires write lock
|
|
380
|
+
_uri = f"{memory_db_path.resolve().as_uri()}?mode=ro"
|
|
381
|
+
_conn = sqlite3.connect(_uri, uri=True, timeout=1.0)
|
|
382
|
+
_conn.execute("PRAGMA query_only=ON")
|
|
383
|
+
try:
|
|
384
|
+
_tables = {
|
|
385
|
+
r[0]
|
|
386
|
+
for r in _conn.execute(
|
|
387
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
388
|
+
).fetchall()
|
|
389
|
+
}
|
|
390
|
+
if "atomic_facts" in _tables:
|
|
391
|
+
# Only live facts (archived = soft-deleted / forgotten).
|
|
392
|
+
# CRIT-fix-3: lifecycle filter avoids counting deleted facts
|
|
393
|
+
# as "stored"; a profile with 1 000 facts all archived
|
|
394
|
+
# should not report store_count=1000 to a non-technical user.
|
|
395
|
+
_r = _conn.execute(
|
|
396
|
+
"SELECT COUNT(*) FROM atomic_facts "
|
|
397
|
+
"WHERE profile_id=? AND lifecycle IN ('active','warm','cold')",
|
|
398
|
+
(profile_id,),
|
|
399
|
+
).fetchone()
|
|
400
|
+
store_count = _r[0] if _r else 0
|
|
401
|
+
|
|
402
|
+
_r = _conn.execute(
|
|
403
|
+
"SELECT COUNT(DISTINCT SUBSTR(created_at, 1, 10)) "
|
|
404
|
+
"FROM atomic_facts "
|
|
405
|
+
"WHERE profile_id=? AND lifecycle IN ('active','warm','cold')",
|
|
406
|
+
(profile_id,),
|
|
407
|
+
).fetchone()
|
|
408
|
+
days_active = _r[0] if _r else 0
|
|
409
|
+
|
|
410
|
+
_r = _conn.execute(
|
|
411
|
+
"SELECT COUNT(*) FROM atomic_facts "
|
|
412
|
+
"WHERE profile_id=? "
|
|
413
|
+
"AND lifecycle IN ('active','warm','cold') "
|
|
414
|
+
"AND created_at >= datetime('now', '-7 days')",
|
|
415
|
+
(profile_id,),
|
|
416
|
+
).fetchone()
|
|
417
|
+
recent_7d = _r[0] if _r else 0
|
|
418
|
+
finally:
|
|
419
|
+
_conn.close()
|
|
420
|
+
except Exception:
|
|
421
|
+
# Any failure (locked, missing, corrupt) → keep zeros;
|
|
422
|
+
# health stays INACTIVE which is the honest fallback.
|
|
423
|
+
pass
|
|
424
|
+
|
|
425
|
+
# ── learning.db : learning_signals (recall proxy) ─────────────────────
|
|
426
|
+
if learning_db_path.exists():
|
|
427
|
+
try:
|
|
428
|
+
_uri = f"{learning_db_path.resolve().as_uri()}?mode=ro"
|
|
429
|
+
_conn = sqlite3.connect(_uri, uri=True, timeout=1.0)
|
|
430
|
+
_conn.execute("PRAGMA query_only=ON")
|
|
431
|
+
try:
|
|
432
|
+
_tables = {
|
|
433
|
+
r[0]
|
|
434
|
+
for r in _conn.execute(
|
|
435
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
436
|
+
).fetchall()
|
|
437
|
+
}
|
|
438
|
+
if "learning_signals" in _tables:
|
|
439
|
+
# CRIT-fix-1: label is "distinct queries" not raw row count.
|
|
440
|
+
# One query that surfaces 10 facts writes 10 rows; COUNT(*)
|
|
441
|
+
# would inflate the number by 10×. COUNT(DISTINCT query)
|
|
442
|
+
# approximates "sessions where you asked for something"
|
|
443
|
+
# which is the intent of recall_count for non-technical users.
|
|
444
|
+
_r = _conn.execute(
|
|
445
|
+
"SELECT COUNT(DISTINCT query) FROM learning_signals "
|
|
446
|
+
"WHERE profile_id=?",
|
|
447
|
+
(profile_id,),
|
|
448
|
+
).fetchone()
|
|
449
|
+
recall_count = _r[0] if _r else 0
|
|
450
|
+
finally:
|
|
451
|
+
_conn.close()
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
454
|
+
|
|
455
|
+
# ── health derivation (plain language) ───────────────────────────────
|
|
456
|
+
if recent_7d >= _ACTIVE_THRESHOLD:
|
|
457
|
+
health_status = "ACTIVE"
|
|
458
|
+
elif recent_7d >= _WARM_THRESHOLD:
|
|
459
|
+
health_status = "WARM"
|
|
460
|
+
elif recent_7d >= 1:
|
|
461
|
+
health_status = "COLD"
|
|
462
|
+
else:
|
|
463
|
+
health_status = "INACTIVE"
|
|
464
|
+
|
|
465
|
+
total_events = store_count # recalls add to proxy separately via recall_count
|
|
466
|
+
memories_per_day = (
|
|
467
|
+
round(store_count / days_active, 1) if days_active > 0 else 0
|
|
468
|
+
)
|
|
469
|
+
raw = (
|
|
470
|
+
0.4 * recall_count
|
|
471
|
+
+ 0.3 * store_count
|
|
472
|
+
+ 0.1 * days_active
|
|
473
|
+
)
|
|
474
|
+
score = (raw / (raw + 20.0)) if raw > 0 else 0.0
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
"health_status": health_status,
|
|
478
|
+
"days_active": days_active,
|
|
479
|
+
"memories_per_day": memories_per_day,
|
|
480
|
+
"total_events": total_events,
|
|
481
|
+
"recall_count": recall_count,
|
|
482
|
+
"store_count": store_count,
|
|
483
|
+
"session_count": 0, # not derivable without dedicated writes
|
|
484
|
+
"engagement_score": round(score, 4),
|
|
485
|
+
# I6 provenance — every figure is traceable to a named source table
|
|
486
|
+
"source": "memory.db:atomic_facts,learning.db:learning_signals",
|
|
487
|
+
}
|
|
@@ -75,11 +75,36 @@ def _graph_not_built_error() -> dict[str, Any]:
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
def _bridge_is_enabled() -> bool:
|
|
79
|
+
"""Whether the code↔memory bridge is switched on in the saved settings."""
|
|
80
|
+
try:
|
|
81
|
+
cfg = _get_service()
|
|
82
|
+
if cfg is not None and getattr(cfg.config, "bridge_enabled", False):
|
|
83
|
+
return True
|
|
84
|
+
# No live service yet (e.g. a tool called before any build): read the
|
|
85
|
+
# saved settings directly rather than reporting "off" by default.
|
|
86
|
+
from superlocalmemory.code_graph.config import CodeGraphConfig
|
|
87
|
+
return bool(CodeGraphConfig.load().bridge_enabled)
|
|
88
|
+
except Exception:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
78
92
|
def _bridge_not_enabled_error() -> dict[str, Any]:
|
|
79
|
-
"""Standard error when bridge is not enabled.
|
|
93
|
+
"""Standard error when the code↔memory bridge is not enabled.
|
|
94
|
+
|
|
95
|
+
The remediation text used to name ``code_graph.bridge.enabled``, which is not
|
|
96
|
+
a key that exists anywhere. The real field is ``bridge_enabled`` in
|
|
97
|
+
``code_graph_config.json``, so anyone who followed this message edited
|
|
98
|
+
nothing that mattered. This helper was also never called from any tool, so
|
|
99
|
+
the message could not appear even when it was correct.
|
|
100
|
+
"""
|
|
80
101
|
return {
|
|
81
102
|
"success": False,
|
|
82
|
-
"error":
|
|
103
|
+
"error": (
|
|
104
|
+
'Code↔memory bridge not enabled. Set "bridge_enabled": true in '
|
|
105
|
+
"~/.superlocalmemory/code_graph_config.json, then run "
|
|
106
|
+
"build_code_graph. Links are created during background maintenance."
|
|
107
|
+
),
|
|
83
108
|
}
|
|
84
109
|
|
|
85
110
|
|
|
@@ -167,7 +192,13 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
|
|
|
167
192
|
p.strip() for p in exclude_patterns.split(",") if p.strip()
|
|
168
193
|
)
|
|
169
194
|
|
|
170
|
-
|
|
195
|
+
# Start from the user's saved settings, then apply this call's
|
|
196
|
+
# arguments. Previously this constructed CodeGraphConfig(**kwargs)
|
|
197
|
+
# from scratch, so every field the caller did not name reverted to a
|
|
198
|
+
# class default — including bridge_enabled, which the setup wizard
|
|
199
|
+
# writes to code_graph_config.json. A user who enabled the code graph
|
|
200
|
+
# during setup therefore had the flag on disk and off in every build.
|
|
201
|
+
config = CodeGraphConfig.load(**config_kwargs)
|
|
171
202
|
global _service
|
|
172
203
|
_service = CodeGraphService(config)
|
|
173
204
|
|
|
@@ -204,8 +235,12 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
|
|
|
204
235
|
if fp in file_groups:
|
|
205
236
|
file_groups[fp][1].append(e)
|
|
206
237
|
|
|
207
|
-
|
|
208
|
-
|
|
238
|
+
# Two-phase commit: all nodes first, then edges.
|
|
239
|
+
# This makes storage order-independent so cross-file CALLS edges
|
|
240
|
+
# (e.g., a.py calls bar() defined in b.py) are never silently
|
|
241
|
+
# dropped because of the file iteration order.
|
|
242
|
+
batch = [(fp, ns, es, fr) for fp, (ns, es, fr) in file_groups.items()]
|
|
243
|
+
store.commit_build_batch(batch)
|
|
209
244
|
|
|
210
245
|
# Build in-memory graph
|
|
211
246
|
engine = GraphEngine(store)
|
|
@@ -357,11 +392,14 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
|
|
|
357
392
|
continue
|
|
358
393
|
try:
|
|
359
394
|
source = full.read_bytes()
|
|
360
|
-
file_nodes, file_edges = parser.parse_file(
|
|
395
|
+
file_nodes, file_edges, file_import_map = parser.parse_file(
|
|
361
396
|
Path(fp), source, lang
|
|
362
397
|
)
|
|
363
398
|
import hashlib
|
|
364
399
|
from superlocalmemory.code_graph.models import FileRecord
|
|
400
|
+
from superlocalmemory.code_graph.parser import (
|
|
401
|
+
_clean_and_resolve_edges,
|
|
402
|
+
)
|
|
365
403
|
fr = FileRecord(
|
|
366
404
|
file_path=fp,
|
|
367
405
|
content_hash=hashlib.sha256(source).hexdigest(),
|
|
@@ -371,7 +409,27 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
|
|
|
371
409
|
edge_count=len(file_edges),
|
|
372
410
|
last_indexed=time.time(),
|
|
373
411
|
)
|
|
374
|
-
|
|
412
|
+
# Wire the resolver for the incremental path.
|
|
413
|
+
# parse_all has _clean_and_resolve_edges built in, but
|
|
414
|
+
# update_code_graph goes through parse_file which emits raw
|
|
415
|
+
# placeholder targets (__call__<name>). Without resolution,
|
|
416
|
+
# Fix B (defensive filter) drops ALL CALLS edges — silent
|
|
417
|
+
# data loss on every incremental update.
|
|
418
|
+
#
|
|
419
|
+
# Load the full DB node set as the resolution universe so
|
|
420
|
+
# Strategy 3 (global heuristic) can match cross-file calls.
|
|
421
|
+
db_nodes, _ = store.get_all_nodes_and_edges()
|
|
422
|
+
resolution_universe = list(file_nodes) + [
|
|
423
|
+
n for n in db_nodes if n.file_path != fp
|
|
424
|
+
]
|
|
425
|
+
resolved_edges = _clean_and_resolve_edges(
|
|
426
|
+
resolution_universe,
|
|
427
|
+
list(file_edges),
|
|
428
|
+
{fp: file_import_map},
|
|
429
|
+
repo,
|
|
430
|
+
config,
|
|
431
|
+
)
|
|
432
|
+
store.store_file_nodes_edges(fp, file_nodes, resolved_edges, fr)
|
|
375
433
|
except Exception as exc:
|
|
376
434
|
logger.warning("Failed to update %s: %s", fp, exc)
|
|
377
435
|
|
|
@@ -700,6 +758,11 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
|
|
|
700
758
|
"total_edges": stats.get("edges", 0),
|
|
701
759
|
"total_code_memory_links": total_links,
|
|
702
760
|
"stale_links": stale_links,
|
|
761
|
+
# Without this, total_code_memory_links == 0 is ambiguous: it
|
|
762
|
+
# means either "no memory mentions your code" or "the feature
|
|
763
|
+
# that creates links is switched off". Those call for opposite
|
|
764
|
+
# actions, so the reader has to be told which one it is.
|
|
765
|
+
"bridge_enabled": _bridge_is_enabled(),
|
|
703
766
|
"built": stats.get("built", False),
|
|
704
767
|
"db_path": stats.get("db_path", ""),
|
|
705
768
|
}
|
|
@@ -1487,6 +1550,14 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
|
|
|
1487
1550
|
if err is not None:
|
|
1488
1551
|
return err
|
|
1489
1552
|
|
|
1553
|
+
# Every answer this tool can give comes from code_memory_links, and
|
|
1554
|
+
# only the bridge populates that table. With the bridge off the query
|
|
1555
|
+
# returns an empty list, which reads as "nothing is stale" — the
|
|
1556
|
+
# strongest possible reassurance, produced by a feature that never
|
|
1557
|
+
# ran. Say so instead.
|
|
1558
|
+
if not _bridge_is_enabled():
|
|
1559
|
+
return _bridge_not_enabled_error()
|
|
1560
|
+
|
|
1490
1561
|
db = _get_db()
|
|
1491
1562
|
|
|
1492
1563
|
if scope == "all":
|
|
@@ -55,9 +55,12 @@ def register_v3_tools(server, get_engine: Callable) -> None:
|
|
|
55
55
|
async def set_mode(mode: str) -> dict:
|
|
56
56
|
"""Switch operating mode (a, b, or c).
|
|
57
57
|
|
|
58
|
-
Mode A
|
|
59
|
-
|
|
60
|
-
Mode
|
|
58
|
+
Mode A (Local Guardian): Nothing leaves this device. No AI language
|
|
59
|
+
model runs. Fastest and most private.
|
|
60
|
+
Mode B: All data stays on this device. Uses a local Ollama AI model
|
|
61
|
+
to improve recall quality. Requires Ollama installed.
|
|
62
|
+
Mode C: Uses a cloud AI provider (OpenAI, Anthropic, …) for best
|
|
63
|
+
recall quality. Queries leave this device; API key required.
|
|
61
64
|
|
|
62
65
|
Resets the engine to apply the new mode configuration.
|
|
63
66
|
|
|
@@ -377,8 +380,19 @@ def register_v3_tools(server, get_engine: Callable) -> None:
|
|
|
377
380
|
def _mode_description(mode: str) -> str:
|
|
378
381
|
"""Human-readable capability description for a mode (never a legal claim)."""
|
|
379
382
|
descriptions = {
|
|
380
|
-
"a":
|
|
381
|
-
|
|
382
|
-
|
|
383
|
+
"a": (
|
|
384
|
+
"Local Guardian — on-device only: no AI language model runs and "
|
|
385
|
+
"nothing leaves this device. Fastest and most private."
|
|
386
|
+
),
|
|
387
|
+
"b": (
|
|
388
|
+
"Smart Local — on-device plus a local Ollama model: better recall "
|
|
389
|
+
"quality, and nothing leaves this device. Requires Ollama to be "
|
|
390
|
+
"installed and running."
|
|
391
|
+
),
|
|
392
|
+
"c": (
|
|
393
|
+
"Full Power — uses a cloud AI provider (OpenAI, Anthropic, …) for "
|
|
394
|
+
"the best recall quality. Your queries leave this device and an "
|
|
395
|
+
"API key is required."
|
|
396
|
+
),
|
|
383
397
|
}
|
|
384
398
|
return descriptions.get(mode, "Unknown mode")
|
|
@@ -1066,6 +1066,27 @@ class RetrievalEngine:
|
|
|
1066
1066
|
if not applied:
|
|
1067
1067
|
return fused, False, status
|
|
1068
1068
|
|
|
1069
|
+
# The worker can report applied=True while returning scores=null — the
|
|
1070
|
+
# subprocess answers, so the call "succeeded", but there is nothing to
|
|
1071
|
+
# score with. Iterating None here raised TypeError from OUTSIDE the
|
|
1072
|
+
# try/except above (which only wraps the rerank call itself), so the
|
|
1073
|
+
# error escaped into the recall path rather than degrading to the fused
|
|
1074
|
+
# ordering. Fail soft: reranking is a quality improvement on top of a
|
|
1075
|
+
# correct result set, never a correctness requirement.
|
|
1076
|
+
# `not scored` covers None AND an empty sequence. An empty list is the
|
|
1077
|
+
# same defect wearing different clothes: the worker says applied=True but
|
|
1078
|
+
# supplied nothing to rank with. Guarding only None would let [] through
|
|
1079
|
+
# to build an empty score_map, and every candidate would then be scored
|
|
1080
|
+
# against a degenerate min/max — silently shrinking the fused component
|
|
1081
|
+
# by (1 - alpha) while still reporting the rerank as applied.
|
|
1082
|
+
if not scored:
|
|
1083
|
+
logger.warning(
|
|
1084
|
+
"Cross-encoder worker reported applied=True with %s scores; "
|
|
1085
|
+
"falling back to fused ranking for this query.",
|
|
1086
|
+
"null" if scored is None else "empty",
|
|
1087
|
+
)
|
|
1088
|
+
return fused, False, "worker_null_scores"
|
|
1089
|
+
|
|
1069
1090
|
score_map = {fact.fact_id: score for fact, score in scored}
|
|
1070
1091
|
|
|
1071
1092
|
# Min-max normalize CE scores to [0, 1] within the batch instead of
|
|
@@ -110,12 +110,30 @@ def is_remote_cross_encoder_backend(backend: str) -> bool:
|
|
|
110
110
|
return (backend or "").strip().lower() in REMOTE_CROSS_ENCODER_BACKENDS
|
|
111
111
|
|
|
112
112
|
|
|
113
|
-
def validate_remote_reranker_config(
|
|
113
|
+
def validate_remote_reranker_config(
|
|
114
|
+
backend: str,
|
|
115
|
+
endpoint: str,
|
|
116
|
+
trust_plain_http_lan: bool = True,
|
|
117
|
+
) -> str | None:
|
|
114
118
|
"""Return an actionable error string, or None when the pair is coherent.
|
|
115
119
|
|
|
116
120
|
Covers the issue-#103 leftover directly: an endpoint configured against a
|
|
117
121
|
LOCAL backend used to be dropped on the floor by ``SLMConfig.load``. It now
|
|
118
122
|
produces a named error naming both keys and the exact edit to make.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
backend: Value of ``retrieval.cross_encoder_backend``.
|
|
126
|
+
endpoint: Value of ``retrieval.cross_encoder_endpoint``.
|
|
127
|
+
trust_plain_http_lan: When True (the default), numeric RFC1918/ULA/
|
|
128
|
+
link-local addresses may use plain HTTP — the same security posture
|
|
129
|
+
as the local reranker, where memory text only crosses loopback.
|
|
130
|
+
Set to False in hardened deployments (zero-trust networks, shared
|
|
131
|
+
colocation) to require HTTPS for all non-loopback hosts.
|
|
132
|
+
|
|
133
|
+
Threat model note: trusting a private-LAN address does NOT prevent a
|
|
134
|
+
MITM attack by an adversary on the same physical LAN (e.g. via ARP
|
|
135
|
+
spoofing). This flag means "the LAN is under my control and I accept that
|
|
136
|
+
risk." It is not a claim that RFC1918 traffic is cryptographically secure.
|
|
119
137
|
"""
|
|
120
138
|
backend = (backend or "").strip()
|
|
121
139
|
endpoint = (endpoint or "").strip()
|
|
@@ -139,11 +157,23 @@ def validate_remote_reranker_config(backend: str, endpoint: str) -> str | None:
|
|
|
139
157
|
)
|
|
140
158
|
if not remote:
|
|
141
159
|
return None
|
|
142
|
-
return _validate_endpoint_url(endpoint)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _validate_endpoint_url(
|
|
146
|
-
|
|
160
|
+
return _validate_endpoint_url(endpoint, trust_plain_http_lan=trust_plain_http_lan)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _validate_endpoint_url(
|
|
164
|
+
endpoint: str,
|
|
165
|
+
trust_plain_http_lan: bool = True,
|
|
166
|
+
) -> str | None:
|
|
167
|
+
"""Scheme/host allow-listing for the operator-supplied rerank URL.
|
|
168
|
+
|
|
169
|
+
Plain-HTTP allowances (most-to-least trusted):
|
|
170
|
+
1. Loopback (127.x, ::1, localhost) — always allowed.
|
|
171
|
+
2. Numeric RFC1918/ULA/link-local addresses — allowed when
|
|
172
|
+
``trust_plain_http_lan`` is True (the default). Only numeric
|
|
173
|
+
addresses qualify; bare hostnames are never trusted because DNS is
|
|
174
|
+
mutable and not a trust boundary.
|
|
175
|
+
3. Everything else (public IPs, bare hostnames) — always requires HTTPS.
|
|
176
|
+
"""
|
|
147
177
|
try:
|
|
148
178
|
parsed = urlparse(endpoint)
|
|
149
179
|
except ValueError as exc:
|
|
@@ -177,11 +207,39 @@ def _validate_endpoint_url(endpoint: str) -> str | None:
|
|
|
177
207
|
"retrieval.cross_encoder_api_key; it is sent as a Bearer header "
|
|
178
208
|
"and never logged."
|
|
179
209
|
)
|
|
180
|
-
if parsed.scheme == "http"
|
|
210
|
+
if parsed.scheme == "http":
|
|
211
|
+
hostname = parsed.hostname
|
|
212
|
+
if _is_loopback_host(hostname):
|
|
213
|
+
return None # loopback always allowed regardless of trust flag
|
|
214
|
+
if trust_plain_http_lan and _is_private_lan_host(hostname):
|
|
215
|
+
# Numeric private address on an operator-trusted LAN. Threat model:
|
|
216
|
+
# an attacker on the same physical LAN can still MITM plain HTTP
|
|
217
|
+
# (ARP spoofing). This is allowed because the LAN is assumed to be
|
|
218
|
+
# under the operator's control. Set trust_plain_http_lan=False in
|
|
219
|
+
# hardened/zero-trust environments.
|
|
220
|
+
return None
|
|
221
|
+
if not _is_private_lan_host(hostname):
|
|
222
|
+
# Public IP, CGNAT, or a bare hostname (DNS not trusted as a
|
|
223
|
+
# proof of locality). Bare hostnames that happen to resolve to
|
|
224
|
+
# private IPs are NOT trusted: DNS can be poisoned or changed,
|
|
225
|
+
# so only provably-private numeric addresses are accepted.
|
|
226
|
+
return (
|
|
227
|
+
"retrieval.cross_encoder_endpoint must use HTTPS for this "
|
|
228
|
+
"host. Plain HTTP is allowed only for loopback "
|
|
229
|
+
"(127.x/::1/localhost) and numeric private-LAN addresses "
|
|
230
|
+
"(RFC1918: 10.x, 172.16-31.x, 192.168.x; IPv6 ULA fc00::/7; "
|
|
231
|
+
"link-local 169.254.x/fe80::). "
|
|
232
|
+
"Bare hostnames are not trusted even if they resolve to a "
|
|
233
|
+
"private IP — use a numeric address or configure HTTPS."
|
|
234
|
+
)
|
|
235
|
+
# Private-LAN address but trust_plain_http_lan is False (hardened mode)
|
|
181
236
|
return (
|
|
182
|
-
"retrieval.cross_encoder_endpoint
|
|
183
|
-
"
|
|
184
|
-
"
|
|
237
|
+
"retrieval.cross_encoder_endpoint uses plain HTTP to a "
|
|
238
|
+
"private-LAN address. HTTPS is required because "
|
|
239
|
+
"retrieval.trust_plain_http_lan is set to false. "
|
|
240
|
+
"Either configure a TLS-terminating proxy on the reranker, or "
|
|
241
|
+
"set retrieval.trust_plain_http_lan=true to permit plain HTTP "
|
|
242
|
+
"within your private network (default for new installs)."
|
|
185
243
|
)
|
|
186
244
|
return None
|
|
187
245
|
|
|
@@ -197,6 +255,42 @@ def _is_loopback_host(hostname: str) -> bool:
|
|
|
197
255
|
return False
|
|
198
256
|
|
|
199
257
|
|
|
258
|
+
def _is_private_lan_host(hostname: str) -> bool:
|
|
259
|
+
"""True only for numeric private-range addresses (RFC1918, ULA, link-local).
|
|
260
|
+
|
|
261
|
+
Deliberate non-DNS: bare hostnames (e.g. ``my-reranker.lan``) return False
|
|
262
|
+
even if they currently resolve to a private IP. DNS is mutable and not a
|
|
263
|
+
trust boundary — an adversary who can influence DNS resolution can redirect
|
|
264
|
+
the endpoint to a public host, defeating the locality check. Only numeric
|
|
265
|
+
addresses are provably bound to a private range at configuration time.
|
|
266
|
+
|
|
267
|
+
Accepted ranges (Python 3.11+ ``ipaddress.is_private``):
|
|
268
|
+
IPv4 RFC1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
|
269
|
+
IPv4 link-local: 169.254.0.0/16
|
|
270
|
+
IPv6 ULA: fc00::/7 (includes fd00::/8)
|
|
271
|
+
IPv6 link-local: fe80::/10
|
|
272
|
+
|
|
273
|
+
Excluded ranges (not accepted for plain HTTP):
|
|
274
|
+
CGNAT 100.64.0.0/10 — ISP-shared address space, not operator-controlled
|
|
275
|
+
172.15.0.0/8 and 172.32.0.0/8 — outside the 172.16.0.0/12 boundary
|
|
276
|
+
Public unicast addresses
|
|
277
|
+
|
|
278
|
+
IPv4-mapped IPv6 addresses (``::ffff:192.168.1.1``) are unwrapped to their
|
|
279
|
+
IPv4 equivalent before the range check, so they are handled consistently.
|
|
280
|
+
"""
|
|
281
|
+
host = (hostname or "").rstrip(".").lower()
|
|
282
|
+
try:
|
|
283
|
+
addr = ipaddress.ip_address(host)
|
|
284
|
+
except ValueError:
|
|
285
|
+
# Not a numeric address — bare hostname, not provably private
|
|
286
|
+
return False
|
|
287
|
+
# Unwrap IPv4-mapped IPv6 (::ffff:192.168.1.1 → 192.168.1.1) so the
|
|
288
|
+
# RFC1918 check applies to the IPv4 portion.
|
|
289
|
+
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
|
290
|
+
addr = addr.ipv4_mapped
|
|
291
|
+
return addr.is_private
|
|
292
|
+
|
|
293
|
+
|
|
200
294
|
def normalize_rerank_endpoint(endpoint: str) -> str:
|
|
201
295
|
"""Append ``/rerank`` when the URL stops at the API root.
|
|
202
296
|
|
|
@@ -370,8 +464,11 @@ class RemoteReranker:
|
|
|
370
464
|
api_key: str = "",
|
|
371
465
|
backend: str = "openai",
|
|
372
466
|
timeout_seconds: float = _DEFAULT_READ_TIMEOUT_S,
|
|
467
|
+
trust_plain_http_lan: bool = True,
|
|
373
468
|
) -> None:
|
|
374
|
-
error = validate_remote_reranker_config(
|
|
469
|
+
error = validate_remote_reranker_config(
|
|
470
|
+
backend, endpoint, trust_plain_http_lan=trust_plain_http_lan,
|
|
471
|
+
)
|
|
375
472
|
if error:
|
|
376
473
|
raise RemoteRerankerConfigError(error)
|
|
377
474
|
|