superlocalmemory 3.7.8 → 3.8.0
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/.claude-plugin/marketplace.json +1 -1
- package/ATTRIBUTION.md +1 -3
- package/CHANGELOG.md +69 -0
- package/README.md +199 -29
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +8 -8
- package/plugin/agents/slm-governance-advisor.md +80 -0
- package/plugin/agents/slm-loop-runner.md +71 -0
- package/plugin/agents/slm-memory-advisor.md +10 -5
- package/plugin/agents/slm-optimize-advisor.md +9 -3
- package/plugin/commands/slm-loop.md +31 -0
- package/plugin/hooks/hooks.json +79 -0
- package/plugin/requirements.txt +1 -1
- package/plugin/scripts/slm-launch +46 -7
- package/plugin/settings.json +9 -0
- package/plugin/skills/slm-cache/SKILL.md +9 -1
- package/plugin/skills/slm-compress/SKILL.md +8 -1
- package/plugin/skills/slm-governance/SKILL.md +248 -0
- package/plugin/skills/slm-graph/SKILL.md +17 -3
- package/plugin/skills/slm-loop/SKILL.md +99 -0
- package/plugin/skills/slm-mesh/SKILL.md +282 -0
- package/plugin/skills/slm-profile/SKILL.md +148 -0
- package/plugin/skills/slm-recall/SKILL.md +46 -10
- package/plugin/skills/slm-remember/SKILL.md +48 -1
- package/plugin/skills/slm-scope/SKILL.md +176 -0
- package/plugin/skills/slm-session/SKILL.md +24 -1
- package/plugin/skills/slm-status/SKILL.md +18 -1
- package/plugin-src/agents/slm-governance-advisor.md +80 -0
- package/plugin-src/agents/slm-loop-runner.md +71 -0
- package/plugin-src/agents/slm-memory-advisor.md +10 -5
- package/plugin-src/agents/slm-optimize-advisor.md +9 -3
- package/plugin-src/commands/slm-loop.md +31 -0
- package/plugin-src/hooks/hooks.json +79 -0
- package/plugin-src/manifest.json +7 -2
- package/plugin-src/requirements.txt +1 -1
- package/plugin-src/rules/AGENTS.md +57 -18
- package/plugin-src/rules/CLAUDE.md.fragment +8 -8
- package/plugin-src/scripts/slm-launch +46 -7
- package/plugin-src/settings.json +9 -0
- package/plugin-src/skills/slm-cache/SKILL.md +9 -1
- package/plugin-src/skills/slm-compress/SKILL.md +8 -1
- package/plugin-src/skills/slm-governance/SKILL.md +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +17 -3
- package/plugin-src/skills/slm-loop/SKILL.md +99 -0
- package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
- package/plugin-src/skills/slm-profile/SKILL.md +148 -0
- package/plugin-src/skills/slm-recall/SKILL.md +46 -10
- package/plugin-src/skills/slm-remember/SKILL.md +48 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +24 -1
- package/plugin-src/skills/slm-status/SKILL.md +18 -1
- package/pyproject.toml +1 -1
- package/scripts/postinstall/validation.js +2 -0
- package/scripts/postinstall-interactive.js +74 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/__init__.py +3 -0
- package/src/superlocalmemory/access/rbac.py +477 -0
- package/src/superlocalmemory/cli/commands.py +94 -10
- package/src/superlocalmemory/cli/compress_cmd.py +17 -7
- package/src/superlocalmemory/cli/loop_cmd.py +192 -0
- package/src/superlocalmemory/cli/main.py +39 -4
- package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
- package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
- package/src/superlocalmemory/cli/pending_store.py +49 -13
- package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
- package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
- package/src/superlocalmemory/cli/setup_wizard.py +22 -13
- package/src/superlocalmemory/compliance/audit.py +6 -0
- package/src/superlocalmemory/compliance/gdpr.py +128 -138
- package/src/superlocalmemory/compliance/retention.py +176 -45
- package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
- package/src/superlocalmemory/core/community_summary.py +267 -0
- package/src/superlocalmemory/core/config.py +216 -3
- package/src/superlocalmemory/core/consolidation_engine.py +95 -22
- package/src/superlocalmemory/core/context_cache.py +61 -18
- package/src/superlocalmemory/core/embedding_worker.py +17 -2
- package/src/superlocalmemory/core/embeddings.py +12 -1
- package/src/superlocalmemory/core/engine.py +17 -1
- package/src/superlocalmemory/core/engine_ingestion.py +29 -0
- package/src/superlocalmemory/core/engine_wiring.py +13 -0
- package/src/superlocalmemory/core/entity_community.py +178 -0
- package/src/superlocalmemory/core/graph_analyzer.py +39 -2
- package/src/superlocalmemory/core/graph_pruner.py +13 -8
- package/src/superlocalmemory/core/key_expander.py +138 -0
- package/src/superlocalmemory/core/maintenance.py +23 -0
- package/src/superlocalmemory/core/modes.py +1 -1
- package/src/superlocalmemory/core/mutations.py +2 -2
- package/src/superlocalmemory/core/pii.py +105 -0
- package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
- package/src/superlocalmemory/core/recall_pipeline.py +2 -0
- package/src/superlocalmemory/core/recall_worker.py +20 -6
- package/src/superlocalmemory/core/scale_engine.py +60 -1
- package/src/superlocalmemory/core/security_primitives.py +40 -2
- package/src/superlocalmemory/core/store_pipeline.py +35 -11
- package/src/superlocalmemory/core/worker_pool.py +21 -6
- package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
- package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
- package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
- package/src/superlocalmemory/evolution/evolution_store.py +122 -45
- package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
- package/src/superlocalmemory/evolution/model_selection.py +160 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
- package/src/superlocalmemory/evolution/triggers.py +22 -13
- package/src/superlocalmemory/graph/cozo_backend.py +43 -20
- package/src/superlocalmemory/hooks/adapter_base.py +5 -1
- package/src/superlocalmemory/hooks/auto_recall.py +13 -1
- package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
- package/src/superlocalmemory/hooks/codex_assets.py +64 -5
- package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
- package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
- package/src/superlocalmemory/hooks/portable_kit.py +114 -1
- package/src/superlocalmemory/infra/backup.py +12 -1
- package/src/superlocalmemory/infra/daemon_identity.py +40 -4
- package/src/superlocalmemory/infra/data_root.py +43 -4
- package/src/superlocalmemory/infra/event_bus.py +107 -24
- package/src/superlocalmemory/infra/rate_limiter.py +93 -0
- package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
- package/src/superlocalmemory/ingestion/credentials.py +1 -1
- package/src/superlocalmemory/learning/cross_project.py +28 -19
- package/src/superlocalmemory/learning/reward_proxy.py +42 -9
- package/src/superlocalmemory/loops/__init__.py +56 -0
- package/src/superlocalmemory/loops/budget.py +58 -0
- package/src/superlocalmemory/loops/engine.py +164 -0
- package/src/superlocalmemory/loops/ledger.py +243 -0
- package/src/superlocalmemory/loops/models.py +152 -0
- package/src/superlocalmemory/loops/rules.py +52 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
- package/src/superlocalmemory/mcp/profiles.py +103 -0
- package/src/superlocalmemory/mcp/server.py +21 -49
- package/src/superlocalmemory/mcp/tools_active.py +4 -7
- package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
- package/src/superlocalmemory/mcp/tools_core.py +8 -1
- package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
- package/src/superlocalmemory/mcp/tools_loops.py +300 -0
- package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
- package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
- package/src/superlocalmemory/mesh/broker.py +237 -129
- package/src/superlocalmemory/mesh/remote_sync.py +50 -8
- package/src/superlocalmemory/optimize/NOTICE +1 -6
- package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
- package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
- package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
- package/src/superlocalmemory/optimize/compress/align.py +32 -26
- package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
- package/src/superlocalmemory/optimize/compress/router.py +105 -22
- package/src/superlocalmemory/optimize/config/defaults.py +1 -1
- package/src/superlocalmemory/optimize/config/schema.py +87 -4
- package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
- package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
- package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
- package/src/superlocalmemory/optimize/storage/db.py +38 -9
- package/src/superlocalmemory/optimize/storage/schema.py +10 -0
- package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
- package/src/superlocalmemory/retrieval/engine.py +168 -26
- package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
- package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
- package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
- package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
- package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
- package/src/superlocalmemory/retrieval/time_window.py +181 -0
- package/src/superlocalmemory/server/api.py +4 -4
- package/src/superlocalmemory/server/profile_runtime.py +125 -8
- package/src/superlocalmemory/server/rbac_enforce.py +142 -0
- package/src/superlocalmemory/server/recall_health.py +24 -3
- package/src/superlocalmemory/server/recall_serializer.py +19 -1
- package/src/superlocalmemory/server/routes/abstraction.py +115 -0
- package/src/superlocalmemory/server/routes/agents.py +128 -38
- package/src/superlocalmemory/server/routes/backup.py +34 -10
- package/src/superlocalmemory/server/routes/behavioral.py +13 -12
- package/src/superlocalmemory/server/routes/brain.py +21 -5
- package/src/superlocalmemory/server/routes/chat.py +10 -5
- package/src/superlocalmemory/server/routes/compliance.py +171 -21
- package/src/superlocalmemory/server/routes/config_api.py +436 -0
- package/src/superlocalmemory/server/routes/data_io.py +30 -8
- package/src/superlocalmemory/server/routes/entity.py +9 -4
- package/src/superlocalmemory/server/routes/events.py +24 -8
- package/src/superlocalmemory/server/routes/evolution.py +135 -17
- package/src/superlocalmemory/server/routes/helpers.py +16 -1
- package/src/superlocalmemory/server/routes/ingest.py +7 -4
- package/src/superlocalmemory/server/routes/insights.py +3 -3
- package/src/superlocalmemory/server/routes/learning.py +14 -14
- package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
- package/src/superlocalmemory/server/routes/memories.py +182 -57
- package/src/superlocalmemory/server/routes/mesh.py +95 -15
- package/src/superlocalmemory/server/routes/optimize.py +33 -1
- package/src/superlocalmemory/server/routes/prewarm.py +2 -0
- package/src/superlocalmemory/server/routes/profiles.py +63 -17
- package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
- package/src/superlocalmemory/server/routes/rbac.py +367 -0
- package/src/superlocalmemory/server/routes/stats.py +13 -6
- package/src/superlocalmemory/server/routes/tiers.py +11 -9
- package/src/superlocalmemory/server/routes/v3_api.py +183 -69
- package/src/superlocalmemory/server/routes/ws.py +5 -2
- package/src/superlocalmemory/server/security_middleware.py +12 -5
- package/src/superlocalmemory/server/ui.py +20 -5
- package/src/superlocalmemory/server/unified_daemon.py +384 -56
- package/src/superlocalmemory/server/write_identity.py +38 -8
- package/src/superlocalmemory/storage/database.py +265 -53
- package/src/superlocalmemory/storage/migration_runner.py +53 -0
- package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
- package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
- package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
- package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
- package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
- package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
- package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/storage/schema.py +87 -0
- package/src/superlocalmemory/storage/schema_v343.py +24 -12
- package/src/superlocalmemory/trust/gate.py +49 -8
- package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
- package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
- package/src/superlocalmemory/ui/css/design-system.css +621 -0
- package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
- package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
- package/src/superlocalmemory/ui/favicon.svg +35 -4
- package/src/superlocalmemory/ui/index.html +306 -173
- package/src/superlocalmemory/ui/js/brain.js +5 -20
- package/src/superlocalmemory/ui/js/core.js +47 -31
- package/src/superlocalmemory/ui/js/dashboard.js +314 -63
- package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
- package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
- package/src/superlocalmemory/ui/js/math-health.js +1 -1
- package/src/superlocalmemory/ui/js/memories.js +15 -4
- package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
- package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
- package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
- package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
- package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
- package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
- package/src/superlocalmemory/ui/js/od-agents.js +544 -0
- package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
- package/src/superlocalmemory/ui/js/od-backup.js +780 -0
- package/src/superlocalmemory/ui/js/od-brain.js +779 -0
- package/src/superlocalmemory/ui/js/od-entities.js +579 -0
- package/src/superlocalmemory/ui/js/od-graph.js +593 -0
- package/src/superlocalmemory/ui/js/od-health.js +539 -0
- package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
- package/src/superlocalmemory/ui/js/od-memories.js +887 -0
- package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
- package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
- package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
- package/src/superlocalmemory/ui/js/od-shell.js +593 -0
- package/src/superlocalmemory/ui/js/od-skills.js +573 -0
- package/src/superlocalmemory/ui/js/od-team.js +258 -0
- package/src/superlocalmemory/ui/js/profiles.js +159 -46
- package/src/superlocalmemory/ui/js/settings.js +2 -2
- package/src/superlocalmemory/ui/js/timeline.js +34 -5
- package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
- package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
- package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
|
@@ -62,6 +62,7 @@ def _get_engine():
|
|
|
62
62
|
def _handle_recall(
|
|
63
63
|
query: str, limit: int, session_id: str = "", fast: bool = False,
|
|
64
64
|
include_global: bool | None = None, include_shared: bool | None = None,
|
|
65
|
+
window: str | None = None,
|
|
65
66
|
) -> dict:
|
|
66
67
|
engine = _get_engine()
|
|
67
68
|
# v3.6.15 multi-scope: None flags let engine.recall resolve the configured
|
|
@@ -70,11 +71,19 @@ def _handle_recall(
|
|
|
70
71
|
response = engine.recall(
|
|
71
72
|
query, limit=limit, session_id=session_id or None, fast=bool(fast),
|
|
72
73
|
include_global=include_global, include_shared=include_shared,
|
|
74
|
+
window=window or None,
|
|
73
75
|
)
|
|
74
76
|
|
|
75
|
-
# Batch-fetch original memory text for all results
|
|
77
|
+
# Batch-fetch original memory text for all results. Retrieval already
|
|
78
|
+
# enforced scope, so resolve content for everything it returned (own +
|
|
79
|
+
# global + shared-with-me); another tenant's PRIVATE content still can't
|
|
80
|
+
# resolve. Using the raw (possibly-None) recall flags here would drop
|
|
81
|
+
# content for legitimately-recalled global/shared memories.
|
|
76
82
|
memory_ids = list({r.fact.memory_id for r in response.results[:limit] if r.fact.memory_id})
|
|
77
|
-
memory_map = engine._db.get_memory_content_batch(
|
|
83
|
+
memory_map = engine._db.get_memory_content_batch(
|
|
84
|
+
memory_ids, engine.profile_id,
|
|
85
|
+
include_global=True, include_shared=True,
|
|
86
|
+
) if memory_ids else {}
|
|
78
87
|
|
|
79
88
|
# v3.6.6: same shared chokepoint as the daemon HTTP route + CLI fallback,
|
|
80
89
|
# so the MCP WorkerPool subprocess path returns identical budgeted output.
|
|
@@ -172,11 +181,15 @@ def _handle_store(content: str, metadata: dict) -> dict:
|
|
|
172
181
|
def _handle_get_memory_facts(memory_id: str) -> dict:
|
|
173
182
|
engine = _get_engine()
|
|
174
183
|
pid = engine.profile_id
|
|
175
|
-
# Get original memory content
|
|
176
|
-
mem_map = engine._db.get_memory_content_batch(
|
|
184
|
+
# Get original memory content (C4: tenant-scoped; global/shared resolvable)
|
|
185
|
+
mem_map = engine._db.get_memory_content_batch(
|
|
186
|
+
[memory_id], pid, include_global=True, include_shared=True,
|
|
187
|
+
)
|
|
177
188
|
original = mem_map.get(memory_id, "")
|
|
178
|
-
# Get child facts
|
|
179
|
-
facts
|
|
189
|
+
# Get child facts — same scope as the content fetch so a shared/global
|
|
190
|
+
# memory's facts are not silently empty.
|
|
191
|
+
facts = engine._db.get_facts_by_memory_id(
|
|
192
|
+
memory_id, pid, include_global=True, include_shared=True)
|
|
180
193
|
fact_list = []
|
|
181
194
|
for f in facts:
|
|
182
195
|
fact_list.append({
|
|
@@ -308,6 +321,7 @@ def _worker_main() -> None:
|
|
|
308
321
|
req.get("session_id", ""), bool(req.get("fast", False)),
|
|
309
322
|
include_global=req.get("include_global"),
|
|
310
323
|
include_shared=req.get("include_shared"),
|
|
324
|
+
window=req.get("window"),
|
|
311
325
|
)
|
|
312
326
|
_respond(result)
|
|
313
327
|
elif cmd == "store":
|
|
@@ -116,6 +116,13 @@ class ScaleEngineManager:
|
|
|
116
116
|
# This command reads persisted state, not the live daemon. Never
|
|
117
117
|
# turn a last-known backend row into a present-tense routing claim.
|
|
118
118
|
"active": {"cozo": False, "lance": False},
|
|
119
|
+
# LOW-2 (3.7.9): after promote the daemon must restart before the
|
|
120
|
+
# backends actually serve; surface that explicitly so `status` isn't
|
|
121
|
+
# mistaken for "promotion failed".
|
|
122
|
+
"daemon_must_restart_to_activate": (
|
|
123
|
+
state == "promoted"
|
|
124
|
+
and not any(v == "active" for v in runtime.values())
|
|
125
|
+
),
|
|
119
126
|
"last_daemon_observation": runtime,
|
|
120
127
|
"paths_present": paths_present,
|
|
121
128
|
"retrieval_routing": (
|
|
@@ -319,6 +326,8 @@ class ScaleEngineManager:
|
|
|
319
326
|
raise ScaleEngineError(
|
|
320
327
|
f"projection parity failed: canonical={canonical}, observed={observed}"
|
|
321
328
|
)
|
|
329
|
+
with self._readonly_connection() as conn:
|
|
330
|
+
self._verify_content_sample(conn, cozo, canonical)
|
|
322
331
|
manifest.update({"state": "verified", "verified_at": _utc_now(), "observed": observed})
|
|
323
332
|
self._write_manifest(stage_dir, manifest)
|
|
324
333
|
self.config.scale_engine_state = "verified"
|
|
@@ -510,7 +519,31 @@ class ScaleEngineManager:
|
|
|
510
519
|
).fetchone()[0]
|
|
511
520
|
edges = count_logical_edges(conn, self.profile_id)
|
|
512
521
|
vectors = count_canonical_vectors(conn, self.profile_id)
|
|
513
|
-
|
|
522
|
+
fact_entity = self._count_fact_entity_links(conn)
|
|
523
|
+
return {
|
|
524
|
+
"entities": int(nodes),
|
|
525
|
+
"edges": int(edges),
|
|
526
|
+
"vectors": int(vectors),
|
|
527
|
+
"fact_entity": int(fact_entity),
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
def _count_fact_entity_links(self, conn: sqlite3.Connection) -> int:
|
|
531
|
+
"""Count fact->entity bridge rows exactly as ``bulk_import_from_sqlite``
|
|
532
|
+
projects them: dedup entity IDs per fact, drop empties. This bridge is
|
|
533
|
+
what lets Cozo map a query seed into the fact graph; if its import
|
|
534
|
+
silently fails, count parity on entities/edges/vectors still passes but
|
|
535
|
+
entity recall returns empty — so it must be verified explicitly."""
|
|
536
|
+
total = 0
|
|
537
|
+
for (raw,) in conn.execute(
|
|
538
|
+
"SELECT canonical_entities_json FROM atomic_facts WHERE profile_id=?",
|
|
539
|
+
(self.profile_id,),
|
|
540
|
+
):
|
|
541
|
+
try:
|
|
542
|
+
entity_ids = json.loads(raw or "[]")
|
|
543
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
544
|
+
continue
|
|
545
|
+
total += sum(1 for eid in dict.fromkeys(entity_ids) if eid)
|
|
546
|
+
return total
|
|
514
547
|
|
|
515
548
|
def _observed_counts(self, cozo: Any, lance: Any) -> dict[str, int]:
|
|
516
549
|
graph = cozo.health_check()
|
|
@@ -521,8 +554,34 @@ class ScaleEngineManager:
|
|
|
521
554
|
"entities": int(graph["entities"]),
|
|
522
555
|
"edges": int(graph["edges"]),
|
|
523
556
|
"vectors": int(vector["vectors"]),
|
|
557
|
+
"fact_entity": int(graph.get("fact_entity", 0)),
|
|
524
558
|
}
|
|
525
559
|
|
|
560
|
+
def _verify_content_sample(
|
|
561
|
+
self, conn: sqlite3.Connection, cozo: Any, canonical: dict[str, int]
|
|
562
|
+
) -> None:
|
|
563
|
+
"""Content parity: count equality does not prove the import reproduced
|
|
564
|
+
the correct rows. Compare the projected entity-ID set against canonical
|
|
565
|
+
SQLite (bounded), catching a projection that has the right entity count
|
|
566
|
+
but the wrong identities."""
|
|
567
|
+
getter = getattr(cozo, "entity_ids", None)
|
|
568
|
+
n = int(canonical.get("entities", 0))
|
|
569
|
+
if not callable(getter) or n == 0:
|
|
570
|
+
return # backend cannot sample, or nothing to compare
|
|
571
|
+
observed_ids = set(getter(limit=n))
|
|
572
|
+
rows = conn.execute(
|
|
573
|
+
"SELECT entity_id FROM canonical_entities WHERE profile_id=?",
|
|
574
|
+
(self.profile_id,),
|
|
575
|
+
).fetchall()
|
|
576
|
+
expected_ids = {r[0] for r in rows}
|
|
577
|
+
if expected_ids != observed_ids:
|
|
578
|
+
missing = sorted(expected_ids - observed_ids)[:5]
|
|
579
|
+
extra = sorted(observed_ids - expected_ids)[:5]
|
|
580
|
+
raise ScaleEngineError(
|
|
581
|
+
"projection content parity failed: entity IDs diverged "
|
|
582
|
+
f"(missing sample={missing}, unexpected sample={extra})"
|
|
583
|
+
)
|
|
584
|
+
|
|
526
585
|
def _projection_fingerprint(
|
|
527
586
|
self, conn: sqlite3.Connection, counts: dict[str, int]
|
|
528
587
|
) -> str:
|
|
@@ -399,6 +399,35 @@ def redact_secrets(text: str, *, entropy_threshold: float = 4.5,
|
|
|
399
399
|
# ---------------------------------------------------------------------------
|
|
400
400
|
|
|
401
401
|
|
|
402
|
+
def harden_db_perms(db_path: str | Path) -> None:
|
|
403
|
+
"""Restrict a database file (and its WAL/SHM sidecars) to owner-only 0600
|
|
404
|
+
and its parent directory to 0700 (C4 encryption-at-rest defense-in-depth).
|
|
405
|
+
|
|
406
|
+
Best-effort and POSIX-only: on Windows or if the file does not exist yet
|
|
407
|
+
this is a silent no-op. The daemon's data dir is already 0700, but the DB
|
|
408
|
+
files themselves shipped 0644 (world-readable); on a shared host that is a
|
|
409
|
+
real exposure even with full-disk encryption at rest.
|
|
410
|
+
"""
|
|
411
|
+
if _is_windows():
|
|
412
|
+
return
|
|
413
|
+
try:
|
|
414
|
+
p = Path(db_path)
|
|
415
|
+
parent = p.parent
|
|
416
|
+
try:
|
|
417
|
+
os.chmod(parent, 0o700)
|
|
418
|
+
except OSError:
|
|
419
|
+
pass
|
|
420
|
+
for suffix in ("", "-wal", "-shm"):
|
|
421
|
+
f = Path(str(p) + suffix)
|
|
422
|
+
if f.exists():
|
|
423
|
+
try:
|
|
424
|
+
os.chmod(f, 0o600)
|
|
425
|
+
except OSError:
|
|
426
|
+
pass
|
|
427
|
+
except Exception: # pragma: no cover — never block DB open on a chmod
|
|
428
|
+
pass
|
|
429
|
+
|
|
430
|
+
|
|
402
431
|
def _install_token_path() -> Path: # pragma: no cover — monkeypatched in tests
|
|
403
432
|
"""Default install-token location — override in tests via monkeypatch."""
|
|
404
433
|
from superlocalmemory.infra.data_root import state_path
|
|
@@ -598,8 +627,17 @@ def run_subprocess_safe(
|
|
|
598
627
|
- Restricted environment by default — only a minimal set of safe keys.
|
|
599
628
|
- Callers may pass an explicit ``env`` to add specific variables.
|
|
600
629
|
|
|
601
|
-
This is the
|
|
602
|
-
|
|
630
|
+
This is the PREFERRED wrapper for any subprocess whose ``argv`` includes
|
|
631
|
+
dynamic or externally-influenced values — routing them through here keeps
|
|
632
|
+
``shell=False``, a mandatory timeout, and a restricted environment.
|
|
633
|
+
|
|
634
|
+
It is not the *only* ``subprocess.run`` call site: a small set of vetted
|
|
635
|
+
callers invoke ``subprocess.run`` directly where they need inherited stdio
|
|
636
|
+
for live progress, a long-lived managed process, or process-group control
|
|
637
|
+
(e.g. model downloads in ``cli/setup_wizard.py``, ``infra/process_reaper``,
|
|
638
|
+
``cli/service_installer``). Those pass only fixed/argv-quoted values — never
|
|
639
|
+
a shell string. Do not read this wrapper as a guarantee that no other
|
|
640
|
+
subprocess call exists; audit new call sites individually. (L-01, 3.7.9)
|
|
603
641
|
"""
|
|
604
642
|
if not isinstance(argv, list):
|
|
605
643
|
raise TypeError("argv must be list[str], shell=False only")
|
|
@@ -208,21 +208,33 @@ def run_store(
|
|
|
208
208
|
if not pre_authorized:
|
|
209
209
|
hooks.run_pre("store", hook_ctx)
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
# Admission gates apply to FRESH submissions only. A materialization pass
|
|
212
|
+
# re-runs this pipeline for content whose queryable projection was already
|
|
213
|
+
# committed at submit (``queryable_fact_ids`` is set). Re-applying admission
|
|
214
|
+
# here — the entropy near-duplicate gate especially — would discard that
|
|
215
|
+
# already-committed fact (return []) and wedge the operation in an endless
|
|
216
|
+
# materialize retry loop ("materialization produced no final facts"). The
|
|
217
|
+
# near-duplicate verdict is expected at materialize: the submitted
|
|
218
|
+
# projection itself is in the gate's window. Admission was already decided
|
|
219
|
+
# at submit (store_fast enforces the same gates), so skip it here.
|
|
220
|
+
is_materialization = bool(queryable_fact_ids)
|
|
221
|
+
|
|
222
|
+
if entropy_gate and not is_materialization and not entropy_gate.should_pass(content):
|
|
212
223
|
return []
|
|
213
224
|
|
|
214
225
|
# v3.5.0: store-side quality gate (H3). Reject prompt-template leakage,
|
|
215
226
|
# empty placeholders, and other non-memory content BEFORE it enters the
|
|
216
227
|
# DB. Uses the shared is_low_quality from core/injection so both store
|
|
217
228
|
# AND injection filter by identical rules. Saves DB IO + recall pollution.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
if not is_materialization:
|
|
230
|
+
try:
|
|
231
|
+
from superlocalmemory.core.injection import is_low_quality
|
|
232
|
+
if is_low_quality(content):
|
|
233
|
+
logger.debug("Store rejected (low-quality content): %s...",
|
|
234
|
+
content[:80].replace("\n", " "))
|
|
235
|
+
return []
|
|
236
|
+
except Exception:
|
|
237
|
+
pass # Best-effort gate; store succeeds if import fails
|
|
226
238
|
|
|
227
239
|
from superlocalmemory.encoding.temporal_parser import TemporalParser
|
|
228
240
|
parser = temporal_parser or TemporalParser()
|
|
@@ -583,14 +595,26 @@ def run_store(
|
|
|
583
595
|
fact.fact_id, exc,
|
|
584
596
|
)
|
|
585
597
|
|
|
598
|
+
# Phase 4b: fact-augmented key expansion (T3b). Index entity aliases /
|
|
599
|
+
# canonical names as BM25 alt-keys so paraphrased queries match. Mode A
|
|
600
|
+
# (entity graph) is zero-LLM and safe on the hot store path; LLM
|
|
601
|
+
# paraphrase enrichment (Mode B/C) is left to background consolidation.
|
|
602
|
+
try:
|
|
603
|
+
from superlocalmemory.core.key_expander import KeyExpander
|
|
604
|
+
_alt_keys = KeyExpander(db).expand(fact, profile_id, mode="a")
|
|
605
|
+
if _alt_keys:
|
|
606
|
+
db.upsert_fact_expansion(fact.fact_id, _alt_keys)
|
|
607
|
+
except Exception as exc:
|
|
608
|
+
logger.debug("Key expansion skipped for %s: %s", fact.fact_id, exc)
|
|
609
|
+
|
|
586
610
|
if observation_builder:
|
|
587
611
|
for eid in fact.canonical_entities:
|
|
588
612
|
observation_builder.update_profile(eid, fact, profile_id)
|
|
589
613
|
|
|
590
|
-
# Increment fact_count for each linked canonical entity
|
|
614
|
+
# Increment fact_count for each linked canonical entity (scoped to profile).
|
|
591
615
|
for eid in fact.canonical_entities:
|
|
592
616
|
try:
|
|
593
|
-
db.increment_entity_fact_count(eid)
|
|
617
|
+
db.increment_entity_fact_count(eid, profile_id)
|
|
594
618
|
except Exception:
|
|
595
619
|
pass # Non-critical — entity may have been deleted
|
|
596
620
|
if scene_builder:
|
|
@@ -70,6 +70,7 @@ class WorkerPool:
|
|
|
70
70
|
fast: bool = False,
|
|
71
71
|
include_global: bool | None = None,
|
|
72
72
|
include_shared: bool | None = None,
|
|
73
|
+
window: str | None = None,
|
|
73
74
|
) -> dict:
|
|
74
75
|
"""Run recall in worker subprocess. Returns result dict.
|
|
75
76
|
|
|
@@ -91,6 +92,8 @@ class WorkerPool:
|
|
|
91
92
|
msg["include_global"] = bool(include_global)
|
|
92
93
|
if include_shared is not None:
|
|
93
94
|
msg["include_shared"] = bool(include_shared)
|
|
95
|
+
if window:
|
|
96
|
+
msg["window"] = window
|
|
94
97
|
return self._send(msg)
|
|
95
98
|
|
|
96
99
|
def store(self, content: str, metadata: dict | None = None) -> dict:
|
|
@@ -291,15 +294,27 @@ class WorkerPool:
|
|
|
291
294
|
self._idle_timer.cancel()
|
|
292
295
|
self._idle_timer = None
|
|
293
296
|
if self._proc is not None:
|
|
294
|
-
|
|
297
|
+
proc = self._proc
|
|
298
|
+
pid = proc.pid
|
|
295
299
|
try:
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
300
|
+
proc.stdin.write('{"cmd":"quit"}\n')
|
|
301
|
+
proc.stdin.flush()
|
|
302
|
+
proc.wait(timeout=3)
|
|
299
303
|
except Exception:
|
|
300
304
|
try:
|
|
301
|
-
|
|
302
|
-
|
|
305
|
+
proc.kill()
|
|
306
|
+
proc.wait(timeout=2)
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
309
|
+
# L-CONC-1: deterministically close the pipe fds rather than leaving
|
|
310
|
+
# them to GC. This releases the OS handles immediately and unblocks
|
|
311
|
+
# any orphaned _readline_with_timeout reader thread (its readline
|
|
312
|
+
# returns/raises on the closed pipe), so repeated request timeouts
|
|
313
|
+
# cannot accumulate reader threads or file descriptors. Cross-platform.
|
|
314
|
+
for stream in (proc.stdin, proc.stdout, proc.stderr):
|
|
315
|
+
try:
|
|
316
|
+
if stream is not None:
|
|
317
|
+
stream.close()
|
|
303
318
|
except Exception:
|
|
304
319
|
pass
|
|
305
320
|
self._proc = None
|
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
"""Reflexion self-review over LLM-extracted entities (Wave Q1).
|
|
6
|
+
|
|
7
|
+
After primary LLM extraction (Mode B/C only), one bounded self-review pass
|
|
8
|
+
audits the extracted entities against the source text and:
|
|
9
|
+
|
|
10
|
+
- drops hallucinated entities (not supported by the source), and
|
|
11
|
+
- adds clearly-missed entities (whose exact text appears in the source).
|
|
12
|
+
|
|
13
|
+
This closes the two distinct extraction failure modes the market leaders
|
|
14
|
+
separate: coverage (missed) and grounding (hallucinated). Coverage is the
|
|
15
|
+
"gleaning" idea (re-present what was extracted, ask for misses); grounding is
|
|
16
|
+
the "reflexion" idea (verify each extracted entity is real).
|
|
17
|
+
|
|
18
|
+
Design guarantees:
|
|
19
|
+
- Mode A never reaches here — the caller gates on Mode B/C + LLM available.
|
|
20
|
+
- Fail-open: any error, unavailable LLM, or unparseable output returns the
|
|
21
|
+
input facts unchanged. This module NEVER raises.
|
|
22
|
+
- Immutable: corrected facts are new objects; inputs are not mutated.
|
|
23
|
+
- Bounded: one LLM call, capped facts reviewed, capped entities per fact.
|
|
24
|
+
|
|
25
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
26
|
+
License: AGPL-3.0-or-later
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import re
|
|
34
|
+
from dataclasses import replace
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from superlocalmemory.storage.models import AtomicFact
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
_MAX_ENTITIES_PER_FACT = 12
|
|
42
|
+
_MIN_ENTITY_LEN = 2
|
|
43
|
+
|
|
44
|
+
_SYSTEM = (
|
|
45
|
+
"You are a precise information-extraction auditor. You verify that "
|
|
46
|
+
"entities extracted from text are actually supported by that text, and "
|
|
47
|
+
"you catch clearly-named entities that were missed. You never invent "
|
|
48
|
+
"entities. You reply with JSON only."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class EntityReflexion:
|
|
53
|
+
"""Bounded, fail-open reflexion pass over extracted entities."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
llm: Any,
|
|
58
|
+
max_facts: int = 8,
|
|
59
|
+
max_tokens: int = 512,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._llm = llm
|
|
62
|
+
self._max_facts = max(1, int(max_facts))
|
|
63
|
+
self._max_tokens = max(64, int(max_tokens))
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# Public API
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def refine(
|
|
70
|
+
self, source_text: str, facts: list[AtomicFact],
|
|
71
|
+
) -> list[AtomicFact]:
|
|
72
|
+
"""Return facts with entity lists corrected, or unchanged on failure."""
|
|
73
|
+
if not self._llm or not facts or not source_text or not source_text.strip():
|
|
74
|
+
return facts
|
|
75
|
+
try:
|
|
76
|
+
is_avail = getattr(self._llm, "is_available", None)
|
|
77
|
+
if callable(is_avail) and not is_avail():
|
|
78
|
+
return facts
|
|
79
|
+
except Exception:
|
|
80
|
+
return facts
|
|
81
|
+
|
|
82
|
+
subject = facts[: self._max_facts]
|
|
83
|
+
try:
|
|
84
|
+
raw = self._invoke(source_text, subject)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
logger.debug("EntityReflexion LLM call failed: %s", exc)
|
|
87
|
+
return facts
|
|
88
|
+
|
|
89
|
+
corrections = self._parse(raw)
|
|
90
|
+
if not corrections:
|
|
91
|
+
return facts
|
|
92
|
+
try:
|
|
93
|
+
return self._apply(facts, corrections, source_text)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
logger.debug("EntityReflexion apply failed: %s", exc)
|
|
96
|
+
return facts
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Internal
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def _invoke(self, source_text: str, subject: list[AtomicFact]) -> str:
|
|
103
|
+
lines = []
|
|
104
|
+
for i, f in enumerate(subject):
|
|
105
|
+
lines.append(f'{i}: "{f.content}" entities={list(f.entities)}')
|
|
106
|
+
facts_block = "\n".join(lines)
|
|
107
|
+
prompt = (
|
|
108
|
+
"Audit the extracted entities for each fact against the SOURCE "
|
|
109
|
+
"text below.\n"
|
|
110
|
+
"For each fact, report:\n"
|
|
111
|
+
"- drop: entities in the fact's list that are NOT supported by the "
|
|
112
|
+
"source (hallucinated).\n"
|
|
113
|
+
"- add: named entities clearly present in the source but missing "
|
|
114
|
+
"from the fact's list. Only add entities whose exact text appears "
|
|
115
|
+
"in the source.\n\n"
|
|
116
|
+
f"--- SOURCE ---\n{source_text}\n--- END ---\n\n"
|
|
117
|
+
f"--- FACTS ---\n{facts_block}\n--- END ---\n\n"
|
|
118
|
+
'Respond with ONLY a JSON array: '
|
|
119
|
+
'[{"index": <int>, "drop": [..], "add": [..]}]. '
|
|
120
|
+
"Use [] for an empty list. Omit facts that need no change."
|
|
121
|
+
)
|
|
122
|
+
out = self._llm.generate(
|
|
123
|
+
prompt=prompt,
|
|
124
|
+
system=_SYSTEM,
|
|
125
|
+
temperature=0.0,
|
|
126
|
+
max_tokens=self._max_tokens,
|
|
127
|
+
)
|
|
128
|
+
return out if isinstance(out, str) else str(out or "")
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _parse(raw: str) -> list[dict]:
|
|
132
|
+
if not raw or not raw.strip():
|
|
133
|
+
return []
|
|
134
|
+
match = re.search(r"\[.*\]", raw, re.DOTALL)
|
|
135
|
+
if not match:
|
|
136
|
+
return []
|
|
137
|
+
try:
|
|
138
|
+
data = json.loads(match.group())
|
|
139
|
+
except (json.JSONDecodeError, ValueError):
|
|
140
|
+
return []
|
|
141
|
+
if not isinstance(data, list):
|
|
142
|
+
return []
|
|
143
|
+
return [d for d in data if isinstance(d, dict)]
|
|
144
|
+
|
|
145
|
+
def _apply(
|
|
146
|
+
self,
|
|
147
|
+
facts: list[AtomicFact],
|
|
148
|
+
corrections: list[dict],
|
|
149
|
+
source_text: str,
|
|
150
|
+
) -> list[AtomicFact]:
|
|
151
|
+
by_index: dict[int, dict] = {}
|
|
152
|
+
for c in corrections:
|
|
153
|
+
idx = c.get("index")
|
|
154
|
+
if isinstance(idx, int) and 0 <= idx < self._max_facts:
|
|
155
|
+
by_index[idx] = c
|
|
156
|
+
|
|
157
|
+
src_low = source_text.lower()
|
|
158
|
+
out: list[AtomicFact] = []
|
|
159
|
+
for i, fact in enumerate(facts):
|
|
160
|
+
c = by_index.get(i)
|
|
161
|
+
if c is None:
|
|
162
|
+
out.append(fact)
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
ents = list(fact.entities)
|
|
166
|
+
drop = {
|
|
167
|
+
str(d).strip().lower()
|
|
168
|
+
for d in _as_list(c.get("drop"))
|
|
169
|
+
if str(d).strip()
|
|
170
|
+
}
|
|
171
|
+
if drop:
|
|
172
|
+
ents = [e for e in ents if e.strip().lower() not in drop]
|
|
173
|
+
|
|
174
|
+
seen = {e.strip().lower() for e in ents}
|
|
175
|
+
for a in _as_list(c.get("add")):
|
|
176
|
+
cand = str(a).strip()
|
|
177
|
+
low = cand.lower()
|
|
178
|
+
if len(cand) < _MIN_ENTITY_LEN or low in seen:
|
|
179
|
+
continue
|
|
180
|
+
# Grounding guard: only add entities literally present in source.
|
|
181
|
+
if low not in src_low:
|
|
182
|
+
continue
|
|
183
|
+
ents.append(cand)
|
|
184
|
+
seen.add(low)
|
|
185
|
+
if len(ents) >= _MAX_ENTITIES_PER_FACT:
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
if ents != list(fact.entities):
|
|
189
|
+
out.append(replace(fact, entities=ents))
|
|
190
|
+
else:
|
|
191
|
+
out.append(fact)
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _as_list(value: Any) -> list:
|
|
196
|
+
if isinstance(value, list):
|
|
197
|
+
return value
|
|
198
|
+
if value is None:
|
|
199
|
+
return []
|
|
200
|
+
return [value]
|
|
@@ -283,22 +283,22 @@ class EntityResolver:
|
|
|
283
283
|
entity = self._db.get_entity_by_name(name, profile_id)
|
|
284
284
|
if entity is not None:
|
|
285
285
|
resolution[raw] = entity.entity_id
|
|
286
|
-
self._touch_last_seen(entity.entity_id)
|
|
286
|
+
self._touch_last_seen(entity.entity_id, profile_id)
|
|
287
287
|
continue
|
|
288
288
|
|
|
289
289
|
# Tier b: alias match (case-insensitive, indexed)
|
|
290
290
|
entity_id = self._alias_lookup(name, profile_id)
|
|
291
291
|
if entity_id is not None:
|
|
292
292
|
resolution[raw] = entity_id
|
|
293
|
-
self._touch_last_seen(entity_id)
|
|
293
|
+
self._touch_last_seen(entity_id, profile_id)
|
|
294
294
|
continue
|
|
295
295
|
|
|
296
296
|
# Tier c: fuzzy match via Jaro-Winkler
|
|
297
297
|
match_id, score = self._fuzzy_match(name, profile_id)
|
|
298
298
|
if match_id is not None and score >= JARO_WINKLER_AUTO_MERGE:
|
|
299
299
|
resolution[raw] = match_id
|
|
300
|
-
self._persist_alias(match_id, name, score, "jaro_winkler")
|
|
301
|
-
self._touch_last_seen(match_id)
|
|
300
|
+
self._persist_alias(match_id, name, score, "jaro_winkler", profile_id)
|
|
301
|
+
self._touch_last_seen(match_id, profile_id)
|
|
302
302
|
continue
|
|
303
303
|
|
|
304
304
|
# Candidate zone (0.70–0.85): queue for LLM in Mode B/C
|
|
@@ -384,7 +384,7 @@ class EntityResolver:
|
|
|
384
384
|
and removes the merged entity record.
|
|
385
385
|
"""
|
|
386
386
|
# Move aliases from merge -> keep
|
|
387
|
-
aliases = self._db.get_aliases_for_entity(entity_id_merge)
|
|
387
|
+
aliases = self._db.get_aliases_for_entity(entity_id_merge, profile_id)
|
|
388
388
|
for alias in aliases:
|
|
389
389
|
new_alias = EntityAlias(
|
|
390
390
|
alias_id=_new_id(),
|
|
@@ -393,7 +393,7 @@ class EntityResolver:
|
|
|
393
393
|
confidence=alias.confidence,
|
|
394
394
|
source=f"merge_from:{entity_id_merge}",
|
|
395
395
|
)
|
|
396
|
-
self._db.store_alias(new_alias)
|
|
396
|
+
self._db.store_alias(new_alias, profile_id)
|
|
397
397
|
|
|
398
398
|
# Also add the merged entity's canonical name as an alias of keep
|
|
399
399
|
merged = self._db.get_entity_by_name("", "") # placeholder
|
|
@@ -403,7 +403,7 @@ class EntityResolver:
|
|
|
403
403
|
)
|
|
404
404
|
if rows:
|
|
405
405
|
merged_name = str(dict(rows[0])["canonical_name"])
|
|
406
|
-
self._persist_alias(entity_id_keep, merged_name, 1.0, "merge")
|
|
406
|
+
self._persist_alias(entity_id_keep, merged_name, 1.0, "merge", profile_id)
|
|
407
407
|
|
|
408
408
|
# Update atomic_facts: replace entity_id_merge with entity_id_keep
|
|
409
409
|
# in canonical_entities_json column
|
|
@@ -435,14 +435,14 @@ class EntityResolver:
|
|
|
435
435
|
except (json.JSONDecodeError, TypeError):
|
|
436
436
|
continue
|
|
437
437
|
|
|
438
|
-
# Delete the merged entity
|
|
438
|
+
# Delete the merged entity (tenant-scoped)
|
|
439
439
|
self._db.execute(
|
|
440
|
-
"DELETE FROM entity_aliases WHERE entity_id = ?",
|
|
441
|
-
(entity_id_merge,),
|
|
440
|
+
"DELETE FROM entity_aliases WHERE entity_id = ? AND profile_id = ?",
|
|
441
|
+
(entity_id_merge, profile_id),
|
|
442
442
|
)
|
|
443
443
|
self._db.execute(
|
|
444
|
-
"DELETE FROM canonical_entities WHERE entity_id = ?",
|
|
445
|
-
(entity_id_merge,),
|
|
444
|
+
"DELETE FROM canonical_entities WHERE entity_id = ? AND profile_id = ?",
|
|
445
|
+
(entity_id_merge, profile_id),
|
|
446
446
|
)
|
|
447
447
|
logger.info(
|
|
448
448
|
"Merged entity %s into %s (profile=%s)",
|
|
@@ -526,7 +526,7 @@ class EntityResolver:
|
|
|
526
526
|
self._db.store_entity(entity)
|
|
527
527
|
|
|
528
528
|
# Store name as its own alias for uniform lookup
|
|
529
|
-
self._persist_alias(entity.entity_id, name, 1.0, "canonical")
|
|
529
|
+
self._persist_alias(entity.entity_id, name, 1.0, "canonical", profile_id)
|
|
530
530
|
|
|
531
531
|
logger.debug(
|
|
532
532
|
"Created entity '%s' [%s] (type=%s, profile=%s)",
|
|
@@ -540,13 +540,16 @@ class EntityResolver:
|
|
|
540
540
|
alias_text: str,
|
|
541
541
|
confidence: float,
|
|
542
542
|
source: str,
|
|
543
|
+
profile_id: str,
|
|
543
544
|
) -> None:
|
|
544
|
-
"""Store an alias, skipping duplicates."""
|
|
545
|
-
# Check if alias already exists for this entity
|
|
545
|
+
"""Store an alias under a profile, skipping duplicates within it."""
|
|
546
|
+
# Check if alias already exists for this entity IN THIS PROFILE. The
|
|
547
|
+
# dedup must be profile-scoped or one profile's alias suppresses
|
|
548
|
+
# another profile's identical alias for a same-id entity.
|
|
546
549
|
existing = self._db.execute(
|
|
547
550
|
"SELECT alias_id FROM entity_aliases "
|
|
548
|
-
"WHERE entity_id = ? AND LOWER(alias) = LOWER(?)",
|
|
549
|
-
(entity_id, alias_text),
|
|
551
|
+
"WHERE entity_id = ? AND profile_id = ? AND LOWER(alias) = LOWER(?)",
|
|
552
|
+
(entity_id, profile_id, alias_text),
|
|
550
553
|
)
|
|
551
554
|
if existing:
|
|
552
555
|
return
|
|
@@ -557,13 +560,20 @@ class EntityResolver:
|
|
|
557
560
|
confidence=confidence,
|
|
558
561
|
source=source,
|
|
559
562
|
)
|
|
560
|
-
self._db.store_alias(alias)
|
|
563
|
+
self._db.store_alias(alias, profile_id)
|
|
561
564
|
|
|
562
|
-
def _touch_last_seen(self, entity_id: str) -> None:
|
|
563
|
-
"""Update last_seen timestamp on a canonical entity.
|
|
565
|
+
def _touch_last_seen(self, entity_id: str, profile_id: str = "default") -> None:
|
|
566
|
+
"""Update last_seen timestamp on a canonical entity scoped to profile.
|
|
567
|
+
|
|
568
|
+
L-01 fix: the original query had no profile_id guard. Since entity_ids are
|
|
569
|
+
UUIDs today the blast radius is theoretical, but the guard is required for
|
|
570
|
+
defense-in-depth against future import/sharing features that could introduce
|
|
571
|
+
UUID collisions across profiles.
|
|
572
|
+
"""
|
|
564
573
|
self._db.execute(
|
|
565
|
-
"UPDATE canonical_entities SET last_seen = ?
|
|
566
|
-
|
|
574
|
+
"UPDATE canonical_entities SET last_seen = ? "
|
|
575
|
+
"WHERE entity_id = ? AND profile_id = ?",
|
|
576
|
+
(_now(), entity_id, profile_id),
|
|
567
577
|
)
|
|
568
578
|
|
|
569
579
|
# -- Internal: LLM disambiguation (Mode B/C) ---------------------------
|
|
@@ -621,9 +631,9 @@ class EntityResolver:
|
|
|
621
631
|
entity_id = known[name_str]
|
|
622
632
|
resolved[mention_str] = entity_id
|
|
623
633
|
self._persist_alias(
|
|
624
|
-
entity_id, mention_str, 0.9, "llm",
|
|
634
|
+
entity_id, mention_str, 0.9, "llm", profile_id,
|
|
625
635
|
)
|
|
626
|
-
self._touch_last_seen(entity_id)
|
|
636
|
+
self._touch_last_seen(entity_id, profile_id)
|
|
627
637
|
# If LLM says it's itself, leave for caller to create
|
|
628
638
|
return resolved
|
|
629
639
|
|