ltcai 11.0.1 → 11.2.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.
Files changed (140) hide show
  1. package/README.md +55 -43
  2. package/docs/CHANGELOG.md +61 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
  6. package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/PERFORMANCE.md +71 -18
  10. package/docs/TRUST_MODEL.md +1 -1
  11. package/docs/WHY_LATTICE.md +1 -1
  12. package/docs/architecture.md +6 -2
  13. package/docs/kg-schema.md +1 -1
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/embeddings.py +12 -37
  16. package/lattice_brain/gates.py +125 -0
  17. package/lattice_brain/graph/discovery_index.py +30 -32
  18. package/lattice_brain/graph/fusion.py +35 -4
  19. package/lattice_brain/graph/image_vectors.py +230 -0
  20. package/lattice_brain/graph/ingest.py +11 -5
  21. package/lattice_brain/graph/projection.py +66 -8
  22. package/lattice_brain/graph/provenance.py +27 -2
  23. package/lattice_brain/graph/retrieval.py +113 -2
  24. package/lattice_brain/graph/retrieval_docgen.py +6 -6
  25. package/lattice_brain/graph/schema.py +18 -0
  26. package/lattice_brain/graph/store.py +9 -0
  27. package/lattice_brain/graph/vector_index/selector.py +32 -2
  28. package/lattice_brain/ingestion.py +363 -10
  29. package/lattice_brain/multimodal.py +1258 -0
  30. package/lattice_brain/portability.py +169 -32
  31. package/lattice_brain/runtime/multi_agent.py +1 -1
  32. package/lattice_brain/sealed_box.py +244 -0
  33. package/lattice_brain/self_model.py +77 -22
  34. package/lattice_brain/synthesis.py +24 -1
  35. package/latticeai/__init__.py +1 -1
  36. package/latticeai/api/brain_intelligence.py +4 -0
  37. package/latticeai/api/chat.py +11 -0
  38. package/latticeai/api/chat_helpers.py +16 -3
  39. package/latticeai/api/chat_hybrid.py +32 -1
  40. package/latticeai/api/features.py +70 -0
  41. package/latticeai/api/local_files.py +102 -0
  42. package/latticeai/api/memory.py +128 -1
  43. package/latticeai/api/portability.py +39 -4
  44. package/latticeai/api/review_queue.py +126 -0
  45. package/latticeai/api/search.py +16 -2
  46. package/latticeai/core/agent.py +59 -2
  47. package/latticeai/core/agent_prompts.py +66 -0
  48. package/latticeai/core/config.py +4 -1
  49. package/latticeai/core/context_builder.py +98 -11
  50. package/latticeai/core/embedding_providers.py +528 -0
  51. package/latticeai/core/legacy_compatibility.py +1 -1
  52. package/latticeai/core/marketplace.py +1 -1
  53. package/latticeai/core/messages.py +180 -0
  54. package/latticeai/core/model_compat.py +73 -2
  55. package/latticeai/core/workspace_os.py +43 -0
  56. package/latticeai/core/workspace_os_constants.py +1 -1
  57. package/latticeai/core/workspace_reorganization.py +335 -0
  58. package/latticeai/models/model_providers.py +12 -4
  59. package/latticeai/runtime/build_phases.py +33 -2
  60. package/latticeai/runtime/chat_wiring.py +4 -0
  61. package/latticeai/runtime/feature_toggle_wiring.py +163 -0
  62. package/latticeai/runtime/persistence_runtime.py +41 -4
  63. package/latticeai/runtime/router_registration.py +11 -0
  64. package/latticeai/runtime/runtime_context.py +1 -0
  65. package/latticeai/services/app_context.py +8 -0
  66. package/latticeai/services/architecture_readiness.py +1 -1
  67. package/latticeai/services/automation_intelligence.py +22 -2
  68. package/latticeai/services/brain_intelligence.py +123 -7
  69. package/latticeai/services/change_proposals.py +50 -10
  70. package/latticeai/services/command_center.py +10 -4
  71. package/latticeai/services/feature_toggles.py +502 -0
  72. package/latticeai/services/folder_watch.py +122 -1
  73. package/latticeai/services/hybrid_chat.py +56 -5
  74. package/latticeai/services/interop_bridges.py +978 -0
  75. package/latticeai/services/memory_service.py +34 -0
  76. package/latticeai/services/model_capability_registry.py +434 -261
  77. package/latticeai/services/model_catalog.py +95 -61
  78. package/latticeai/services/model_recommendation.py +18 -11
  79. package/latticeai/services/model_runtime.py +1 -1
  80. package/latticeai/services/multimodal_ports.py +112 -0
  81. package/latticeai/services/obsidian_bridge.py +16 -25
  82. package/latticeai/services/product_readiness.py +1 -1
  83. package/latticeai/services/search_service.py +149 -2
  84. package/latticeai/services/self_model_service.py +171 -0
  85. package/latticeai/services/tool_dispatch.py +4 -0
  86. package/latticeai/services/voice_capture.py +27 -1
  87. package/latticeai/setup/auto_setup.py +27 -30
  88. package/latticeai/setup/wizard.py +77 -44
  89. package/package.json +1 -1
  90. package/scripts/check_current_release_docs.mjs +1 -1
  91. package/scripts/check_server_i18n.mjs +1 -0
  92. package/scripts/release_screen_claims.json +22 -0
  93. package/scripts/verify_hf_model_registry.py +253 -218
  94. package/src-tauri/Cargo.lock +1 -1
  95. package/src-tauri/Cargo.toml +1 -1
  96. package/src-tauri/tauri.conf.json +1 -1
  97. package/static/app/asset-manifest.json +37 -37
  98. package/static/app/assets/{Act-D4zSxFR-.js → Act-AWf0SAKp.js} +1 -1
  99. package/static/app/assets/{AdminConsole-w5jBfPt2.js → AdminConsole-D0u8Tiyj.js} +1 -1
  100. package/static/app/assets/{Brain-C2EqQg74.js → Brain-tuhI4sOC.js} +1 -1
  101. package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
  102. package/static/app/assets/BrainSignals-jMYgQ2Ar.js +1 -0
  103. package/static/app/assets/{Capture-DPqpGK8d.js → Capture-CqOSzyPr.js} +1 -1
  104. package/static/app/assets/{CommandPalette-CNf7h5fp.js → CommandPalette-DC0Bzh-I.js} +1 -1
  105. package/static/app/assets/{Library-BN0HYOfc.js → Library-CX-bbhmK.js} +1 -1
  106. package/static/app/assets/{LivingBrain-Dfq_wEDI.js → LivingBrain-DBwhto14.js} +1 -1
  107. package/static/app/assets/{ProductFlow-B-3O0rNV.js → ProductFlow-BHA2cfKI.js} +1 -1
  108. package/static/app/assets/{ReviewCard-gZ-tdqFM.js → ReviewCard-BUhCKRNM.js} +1 -1
  109. package/static/app/assets/{System-BElUcSSw.js → System-Bu2t5hn1.js} +1 -1
  110. package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
  111. package/static/app/assets/{bot--qYHMtkP.js → bot-Cia42c2h.js} +1 -1
  112. package/static/app/assets/brain-DJMoqrwx.js +1 -0
  113. package/static/app/assets/{button-51Z3rsuv.js → button-2j2Ijzgq.js} +1 -1
  114. package/static/app/assets/{circle-pause-CMIiMaQl.js → circle-pause-BEFeWpVW.js} +1 -1
  115. package/static/app/assets/{circle-play-DZoO_cfG.js → circle-play-ujXMcHxl.js} +1 -1
  116. package/static/app/assets/{cpu-Bs6uc9W9.js → cpu-k4awryFq.js} +1 -1
  117. package/static/app/assets/{download-G-2olkWz.js → download-DFbLJ_ig.js} +1 -1
  118. package/static/app/assets/{folder-open-CTOspnmb.js → folder-open-7y_b6xkM.js} +1 -1
  119. package/static/app/assets/{hard-drive-CewHWJhn.js → hard-drive-Bidh02Kr.js} +1 -1
  120. package/static/app/assets/{index-D7Rr-J2Y.js → index-BpYkzcVm.js} +3 -3
  121. package/static/app/assets/index-DwDl9-8Y.css +2 -0
  122. package/static/app/assets/{input-D4w_BZWl.js → input-DSlJJxRs.js} +1 -1
  123. package/static/app/assets/{permissionCopy-CosBEXAZ.js → permissionCopy-Bpb83Hx9.js} +1 -1
  124. package/static/app/assets/{primitives-d0g9pvzS.js → primitives-BCx6TvfG.js} +1 -1
  125. package/static/app/assets/search-Cgy8cCFJ.js +1 -0
  126. package/static/app/assets/{share-2-NmD7e_oV.js → share-2-BH1M-WNi.js} +1 -1
  127. package/static/app/assets/{shield-alert-CcQeMuju.js → shield-alert-BlKdBXcG.js} +1 -1
  128. package/static/app/assets/{textarea-BPAJDc-0.js → textarea-CCWbUfFB.js} +1 -1
  129. package/static/app/assets/{useFocusTrap-C7YLdTBC.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
  130. package/static/app/assets/{useQuery-DRyD9opW.js → useQuery-CXQiwbVT.js} +1 -1
  131. package/static/app/assets/{utils-DG1_ExrP.js → utils-zqPZJxdx.js} +2 -2
  132. package/static/app/assets/{workspace-CWVf3gsI.js → workspace-DXTihhfU.js} +1 -1
  133. package/static/app/index.html +4 -4
  134. package/static/sw.js +1 -1
  135. package/static/app/assets/BrainHome-CvXS6XiQ.js +0 -2
  136. package/static/app/assets/BrainSignals-DOE_KhOU.js +0 -1
  137. package/static/app/assets/arrow-left-CFNIMjhv.js +0 -1
  138. package/static/app/assets/brain-DDCLjRqO.js +0 -1
  139. package/static/app/assets/index-CkzokZAj.css +0 -2
  140. package/static/app/assets/search-BLCYt75v.js +0 -1
@@ -25,6 +25,28 @@ implementations:
25
25
  :func:`resolve_embedder` builds the configured provider and, when that provider
26
26
  is unavailable, degrades to the hash fallback while *reporting* the requested
27
27
  vs. active provider — nothing is silently faked.
28
+
29
+ Vision seam (v11.1.0, Track 3)
30
+ ------------------------------
31
+ Images join the same contract through :class:`VisionEmbeddingProvider`, with two
32
+ deliberate differences from the text side:
33
+
34
+ * **No fallback.** The hash embedder turns *text* into a real, if crude, cosine
35
+ signal. There is no equivalent for pixels: hashing a file path produces a
36
+ vector that says nothing about the picture, so an unavailable vision model is
37
+ reported as unavailable (:class:`EmbeddingUnavailable` /
38
+ ``ResolvedVisionEmbedder.available == False``) and the caller skips the
39
+ embedding instead of storing a decoy.
40
+ * **A separate space by default.** A CLIP-family image vector is not comparable
41
+ with a BGE text vector, so ``space == "image"`` means "index these apart and
42
+ join them by late fusion". Only a genuinely shared-space model may declare
43
+ ``space == "shared"`` (opt-in), and only then can a *text* query be scored
44
+ against image vectors.
45
+
46
+ :class:`VisionCaptioner` is the matching seam for descriptions. Its default
47
+ implementation returns ``None``: a caption is what a vision-language model
48
+ said about an image, so with no VLM loaded there is no caption — never a
49
+ sentence assembled from the filename and passed off as one.
28
50
  """
29
51
 
30
52
  from __future__ import annotations
@@ -650,9 +672,515 @@ def resolve_embedder(
650
672
  return ResolvedEmbedder(prov, requested, prov.provider, False, health, "")
651
673
 
652
674
 
675
+ # ── Vision (image) embedding seam ─────────────────────────────────────────────
676
+ #: CLIP ViT-B/32 width — the most common local image-embedding output.
677
+ DEFAULT_VISION_DIM = 512
678
+ #: Image vectors live in their own index and join text results by late fusion.
679
+ VISION_SPACE_IMAGE = "image"
680
+ #: A genuinely multimodal model (CLIP-style) can share the text space — opt-in.
681
+ VISION_SPACE_SHARED = "shared"
682
+ VISION_SPACES = (VISION_SPACE_IMAGE, VISION_SPACE_SHARED)
683
+ VISION_PROVIDER_TYPES = ("mlx", "custom")
684
+ VISION_TARGET_ENV = "LATTICEAI_VISION_EMBEDDING_TARGET"
685
+ VISION_CAPTION_TARGET_ENV = "LATTICEAI_VISION_CAPTION_TARGET"
686
+
687
+ _KNOWN_VISION_DIMS = {
688
+ "clip-vit-base-patch32": 512,
689
+ "clip-vit-base-patch16": 512,
690
+ "clip-vit-large-patch14": 768,
691
+ "siglip-base-patch16-224": 768,
692
+ "siglip-large-patch16-384": 1024,
693
+ }
694
+
695
+
696
+ def _guess_vision_dim(model: str, default: int) -> int:
697
+ key = str(model or "").split("/")[-1].strip().lower()
698
+ key = key.split(":")[0]
699
+ return _KNOWN_VISION_DIMS.get(key, default)
700
+
701
+
702
+ def _normalize_space(value: Any) -> str:
703
+ space = str(value or "").strip().lower()
704
+ return space if space in VISION_SPACES else VISION_SPACE_IMAGE
705
+
706
+
707
+ class VisionEmbeddingProvider(EmbeddingProvider):
708
+ """Turns an image *file path* into a vector.
709
+
710
+ Subclasses implement :meth:`embed_images`; everything else — the single
711
+ ``embed_image``, L2 normalization, locking the index identity to the width
712
+ the model actually returned, and the refusal to embed text outside a shared
713
+ space — is shared here.
714
+ """
715
+
716
+ provider = "vision"
717
+ grade = "production"
718
+ #: ``"image"`` (own index, late fusion) or ``"shared"`` (same space as text)
719
+ space: str = VISION_SPACE_IMAGE
720
+
721
+ def __init__(self, cfg: _RemoteConfig):
722
+ self._cfg = cfg
723
+ self.dim = int(cfg.dim or DEFAULT_VISION_DIM)
724
+ self.space = _normalize_space(cfg.extra.get("space"))
725
+
726
+ # ── required ──────────────────────────────────────────────────────────
727
+ def embed_images(self, paths: Sequence[str]) -> List[List[float]]:
728
+ raise NotImplementedError
729
+
730
+ # ── derived (shared) ──────────────────────────────────────────────────
731
+ @property
732
+ def shares_text_space(self) -> bool:
733
+ """True when a text query may be scored against these vectors."""
734
+ return self.space == VISION_SPACE_SHARED
735
+
736
+ def embed_image(self, path: str) -> List[float]:
737
+ vectors = self.embed_images([path])
738
+ if not vectors:
739
+ raise EmbeddingUnavailable(f"{self.model_id} returned no vector for {path}")
740
+ return vectors[0]
741
+
742
+ def embed_batch(self, texts: Sequence[str]) -> List[List[float]]:
743
+ """Text side of a multimodal model — only in a shared space.
744
+
745
+ Scoring a BGE query vector against CLIP image vectors produces a number
746
+ with no meaning, so the image-space default refuses rather than
747
+ returning something a caller would rank on.
748
+ """
749
+ if not self.shares_text_space:
750
+ raise EmbeddingUnavailable(
751
+ f"{self.model_id} embeds images into a separate space; text "
752
+ "queries reach image nodes through late fusion, not this index"
753
+ )
754
+ return self._normalize_rows(self._embed_texts_raw(texts))
755
+
756
+ def _embed_texts_raw(self, texts: Sequence[str]) -> List[List[float]]:
757
+ raise NotImplementedError
758
+
759
+ def _normalize_rows(self, rows: Iterable[Any]) -> List[List[float]]:
760
+ """L2-normalize, and lock the index identity to the real width."""
761
+ out: List[List[float]] = []
762
+ for row in rows:
763
+ vec = [float(x) for x in (row or [])]
764
+ if not vec:
765
+ raise EmbeddingUnavailable(f"{self.model_id} produced an empty vector")
766
+ self.dim = len(vec)
767
+ self.model_id = self._model_id_with_dim(self.dim)
768
+ out.append(_l2_normalize(vec))
769
+ return out
770
+
771
+ def metadata(self) -> Dict[str, Any]:
772
+ data = super().metadata()
773
+ data.update(
774
+ {
775
+ "modality": "image",
776
+ "space": self.space,
777
+ "shares_text_space": self.shares_text_space,
778
+ }
779
+ )
780
+ return data
781
+
782
+
783
+ class MLXVisionEmbeddingProvider(VisionEmbeddingProvider):
784
+ """Local CLIP-family image embedder loaded through ``mlx_clip``.
785
+
786
+ Guarded import, opt-in, never a core dependency: the module must expose
787
+ ``load(model)`` returning an encoder with ``encode_image(paths)`` and —
788
+ for a shared space — ``encode_text(texts)``.
789
+ """
790
+
791
+ provider = "mlx-vision"
792
+
793
+ def __init__(self, cfg: _RemoteConfig):
794
+ super().__init__(cfg)
795
+ if not cfg.dim:
796
+ self.dim = _guess_vision_dim(cfg.model, DEFAULT_VISION_DIM)
797
+ self.model_id = f"mlx-vision:{cfg.model}:{self.dim}"
798
+ self._encoder: Optional[Any] = None
799
+
800
+ def _load(self) -> Any:
801
+ if self._encoder is not None:
802
+ return self._encoder
803
+ try: # optional dependency; only imported when this provider is used
804
+ import mlx_clip # type: ignore
805
+
806
+ self._encoder = mlx_clip.load(self._cfg.model)
807
+ except Exception as exc:
808
+ raise EmbeddingUnavailable(f"MLX vision model unavailable: {exc}") from exc
809
+ return self._encoder
810
+
811
+ def embed_images(self, paths: Sequence[str]) -> List[List[float]]:
812
+ encoder = self._load()
813
+ try:
814
+ rows = encoder.encode_image(list(paths))
815
+ except Exception as exc:
816
+ raise EmbeddingUnavailable(f"MLX vision embedding failed: {exc}") from exc
817
+ return self._normalize_rows(rows)
818
+
819
+ def _embed_texts_raw(self, texts: Sequence[str]) -> List[List[float]]:
820
+ encoder = self._load()
821
+ encode_text = getattr(encoder, "encode_text", None)
822
+ if not callable(encode_text):
823
+ raise EmbeddingUnavailable(
824
+ f"{self.model_id} has no text encoder, so it cannot back a shared space"
825
+ )
826
+ try:
827
+ return list(encode_text(list(texts)))
828
+ except Exception as exc:
829
+ raise EmbeddingUnavailable(f"MLX vision text embedding failed: {exc}") from exc
830
+
831
+ def health(self) -> Dict[str, Any]:
832
+ try:
833
+ self._load()
834
+ return {"status": "ok", "detail": f"MLX vision model {self._cfg.model} loaded"}
835
+ except Exception as exc:
836
+ return {"status": "unavailable", "detail": str(exc)}
837
+
838
+
839
+ class CustomVisionEmbeddingProvider(VisionEmbeddingProvider):
840
+ """A user-supplied ``module:callable`` that embeds image paths.
841
+
842
+ The callable receives ``List[str]`` (paths) and returns
843
+ ``List[List[float]]``. Configured via ``LATTICEAI_VISION_EMBEDDING_TARGET``.
844
+ """
845
+
846
+ provider = "custom-vision"
847
+
848
+ def __init__(self, cfg: _RemoteConfig):
849
+ super().__init__(cfg)
850
+ self._target_ref = str(cfg.extra.get("target") or os.getenv(VISION_TARGET_ENV, ""))
851
+ self.model_id = f"custom-vision:{cfg.model or self._target_ref or 'callable'}:{self.dim}"
852
+ self._fn: Optional[Callable[..., Any]] = None
853
+
854
+ def _load(self) -> Callable[..., Any]:
855
+ if self._fn is not None:
856
+ return self._fn
857
+ self._fn = _load_dotted(self._target_ref, VISION_TARGET_ENV, "vision embedding")
858
+ return self._fn
859
+
860
+ def embed_images(self, paths: Sequence[str]) -> List[List[float]]:
861
+ fn = self._load()
862
+ try:
863
+ rows = list(fn(list(paths)))
864
+ except Exception as exc:
865
+ raise EmbeddingUnavailable(f"custom vision embedding failed: {exc}") from exc
866
+ return self._normalize_rows(rows)
867
+
868
+ def _embed_texts_raw(self, texts: Sequence[str]) -> List[List[float]]:
869
+ # A dotted image embedder is one callable over paths; a shared space
870
+ # would need a second, text-side entry point this contract has no slot
871
+ # for. Saying so beats scoring a query against the wrong function.
872
+ raise EmbeddingUnavailable(
873
+ f"{self.model_id} is an image-only callable and has no text encoder"
874
+ )
875
+
876
+ def health(self) -> Dict[str, Any]:
877
+ try:
878
+ self._load()
879
+ return {"status": "ok", "detail": f"custom vision target {self._target_ref} loaded"}
880
+ except Exception as exc:
881
+ return {"status": "unavailable", "detail": str(exc)}
882
+
883
+
884
+ def _load_dotted(ref: str, env_name: str, label: str) -> Callable[..., Any]:
885
+ """Import ``module:callable`` (or ``module.callable``) or explain why not."""
886
+ if not ref:
887
+ raise EmbeddingUnavailable(f"{label} target not configured ({env_name})")
888
+ module_name, _, attr = ref.replace(":", ".").rpartition(".")
889
+ if not module_name:
890
+ raise EmbeddingUnavailable(f"invalid {label} target: {ref}")
891
+ try:
892
+ module = importlib.import_module(module_name)
893
+ return getattr(module, attr) # type: ignore[no-any-return]
894
+ except Exception as exc:
895
+ raise EmbeddingUnavailable(f"{label} target unavailable: {exc}") from exc
896
+
897
+
898
+ def build_vision_provider(
899
+ provider: str,
900
+ *,
901
+ model: str = "",
902
+ dim: int = 0,
903
+ space: str = VISION_SPACE_IMAGE,
904
+ timeout: float = 30.0,
905
+ extra: Optional[Dict[str, Any]] = None,
906
+ ) -> VisionEmbeddingProvider:
907
+ """Construct a vision provider by name. Never makes a network call."""
908
+ kind = str(provider or "").strip().lower()
909
+ cfg = _RemoteConfig(
910
+ model=model,
911
+ dim=int(dim or 0),
912
+ timeout=float(timeout or 30.0),
913
+ extra={"space": space, **(extra or {})},
914
+ )
915
+ if kind == "mlx":
916
+ return MLXVisionEmbeddingProvider(cfg)
917
+ if kind == "custom":
918
+ return CustomVisionEmbeddingProvider(cfg)
919
+ raise ValueError(
920
+ f"unknown vision embedding provider: {provider!r} (expected one of {VISION_PROVIDER_TYPES})"
921
+ )
922
+
923
+
924
+ @dataclass
925
+ class ResolvedVisionEmbedder:
926
+ """A vision provider, or an honest account of why there isn't one."""
927
+
928
+ provider: Optional[VisionEmbeddingProvider]
929
+ requested: str
930
+ health: Dict[str, Any]
931
+ detail: str = ""
932
+
933
+ @property
934
+ def available(self) -> bool:
935
+ return self.provider is not None
936
+
937
+ @property
938
+ def space(self) -> str:
939
+ return self.provider.space if self.provider is not None else VISION_SPACE_IMAGE
940
+
941
+ def as_port(self) -> Optional[Callable[[str], List[float]]]:
942
+ """The one-argument seam Brain Core injects (``None`` when absent).
943
+
944
+ ``lattice_brain`` must not import ``latticeai``, so the ingestion
945
+ pipeline never sees this class — only the callable it hands over.
946
+ """
947
+ if self.provider is None:
948
+ return None
949
+ return self.provider.embed_image
950
+
951
+ def as_dict(self) -> Dict[str, Any]:
952
+ payload: Dict[str, Any] = {
953
+ "requested_provider": self.requested,
954
+ "available": self.available,
955
+ "health": self.health,
956
+ "detail": self.detail,
957
+ }
958
+ if self.provider is not None:
959
+ payload.update(self.provider.metadata())
960
+ return payload
961
+
962
+
963
+ def resolve_vision_embedder(
964
+ provider: str = "",
965
+ *,
966
+ model: str = "",
967
+ dim: int = 0,
968
+ space: str = VISION_SPACE_IMAGE,
969
+ timeout: float = 30.0,
970
+ extra: Optional[Dict[str, Any]] = None,
971
+ probe: bool = True,
972
+ ) -> ResolvedVisionEmbedder:
973
+ """Build the requested vision provider, or report it unavailable.
974
+
975
+ Unlike :func:`resolve_embedder` there is no fallback: a hashed file path is
976
+ not a picture. An empty ``provider`` means the user never asked for image
977
+ embeddings, which is a configuration state rather than a failure.
978
+ """
979
+ requested = str(provider or "").strip().lower()
980
+ if not requested:
981
+ return ResolvedVisionEmbedder(
982
+ None,
983
+ "",
984
+ {"status": "unavailable", "detail": "no vision provider configured"},
985
+ "image embeddings are off; set a vision provider to enable them",
986
+ )
987
+ try:
988
+ prov = build_vision_provider(
989
+ requested, model=model, dim=dim, space=space, timeout=timeout, extra=extra
990
+ )
991
+ except Exception as exc:
992
+ return ResolvedVisionEmbedder(
993
+ None,
994
+ requested,
995
+ {"status": "unavailable", "detail": str(exc)},
996
+ f"could not construct vision provider {requested}",
997
+ )
998
+ if not probe:
999
+ return ResolvedVisionEmbedder(
1000
+ prov, requested, {"status": "unknown", "detail": "not probed"}, ""
1001
+ )
1002
+ try:
1003
+ health = prov.health()
1004
+ except Exception as exc: # a provider probe must never crash startup
1005
+ health = {"status": "unavailable", "detail": str(exc)}
1006
+ if health.get("status") != "ok":
1007
+ return ResolvedVisionEmbedder(
1008
+ None,
1009
+ requested,
1010
+ health,
1011
+ f"{requested} vision model unavailable ({health.get('detail', '')})",
1012
+ )
1013
+ return ResolvedVisionEmbedder(prov, requested, health, "")
1014
+
1015
+
1016
+ # ── Vision captions (a VLM said this, or nobody did) ──────────────────────────
1017
+ class VisionCaptioner:
1018
+ """Describes an image — the null implementation, which describes nothing.
1019
+
1020
+ Every "clever" fallback here is a lie: ``Image IMG_2381.png (JPEG
1021
+ 3024x4032)`` is metadata wearing a caption's clothes, and once it is in the
1022
+ graph nothing downstream can tell it from a model's actual description. So
1023
+ the base class returns ``None`` and :meth:`available` says ``False``.
1024
+ """
1025
+
1026
+ provider = "none"
1027
+ model_id = ""
1028
+
1029
+ def available(self) -> bool:
1030
+ return False
1031
+
1032
+ def caption(self, path: str, *, prompt: str = "") -> Optional[str]:
1033
+ return None
1034
+
1035
+ def health(self) -> Dict[str, Any]:
1036
+ return {"status": "unavailable", "detail": "no vision-language model is loaded"}
1037
+
1038
+ def metadata(self) -> Dict[str, Any]:
1039
+ return {
1040
+ "provider": self.provider,
1041
+ "model": self.model_id,
1042
+ "available": self.available(),
1043
+ }
1044
+
1045
+
1046
+ #: Short, literal instruction — a caption is a description, not an essay.
1047
+ DEFAULT_CAPTION_PROMPT = "Describe this image in one factual sentence."
1048
+
1049
+
1050
+ class MLXVisionCaptioner(VisionCaptioner):
1051
+ """Caption through a locally loaded ``mlx_vlm`` model (guarded import)."""
1052
+
1053
+ provider = "mlx-vlm"
1054
+
1055
+ def __init__(self, model: str, *, prompt: str = DEFAULT_CAPTION_PROMPT, max_tokens: int = 64):
1056
+ self.model_id = str(model or "")
1057
+ self._prompt = prompt or DEFAULT_CAPTION_PROMPT
1058
+ self._max_tokens = max(1, int(max_tokens))
1059
+ self._loaded: Optional[Tuple[Any, Any]] = None
1060
+
1061
+ def _load(self) -> Tuple[Any, Any]:
1062
+ if self._loaded is not None:
1063
+ return self._loaded
1064
+ try: # optional dependency (`pip install "ltcai[local]"`)
1065
+ from mlx_vlm import load as vlm_load # type: ignore
1066
+
1067
+ model, processor = vlm_load(self.model_id)
1068
+ self._loaded = (model, processor)
1069
+ except Exception as exc:
1070
+ raise EmbeddingUnavailable(f"vision-language model unavailable: {exc}") from exc
1071
+ return self._loaded
1072
+
1073
+ def available(self) -> bool:
1074
+ try:
1075
+ self._load()
1076
+ return True
1077
+ except EmbeddingUnavailable:
1078
+ return False
1079
+
1080
+ def caption(self, path: str, *, prompt: str = "") -> Optional[str]:
1081
+ try:
1082
+ model, processor = self._load()
1083
+ from mlx_vlm import generate as vlm_generate # type: ignore
1084
+
1085
+ text = vlm_generate(
1086
+ model,
1087
+ processor,
1088
+ str(path),
1089
+ prompt or self._prompt,
1090
+ max_tokens=self._max_tokens,
1091
+ )
1092
+ except Exception:
1093
+ # A caption the model did not produce is not a caption. Absence is
1094
+ # the honest answer, and every caller already handles it.
1095
+ return None
1096
+ cleaned = str(text or "").strip()
1097
+ return cleaned or None
1098
+
1099
+ def health(self) -> Dict[str, Any]:
1100
+ try:
1101
+ self._load()
1102
+ return {"status": "ok", "detail": f"VLM {self.model_id} loaded"}
1103
+ except EmbeddingUnavailable as exc:
1104
+ return {"status": "unavailable", "detail": str(exc)}
1105
+
1106
+
1107
+ class CustomVisionCaptioner(VisionCaptioner):
1108
+ """A user-supplied ``module:callable`` that captions an image path."""
1109
+
1110
+ provider = "custom-vlm"
1111
+
1112
+ def __init__(self, target: str = ""):
1113
+ self._target_ref = str(target or os.getenv(VISION_CAPTION_TARGET_ENV, ""))
1114
+ self.model_id = self._target_ref
1115
+ self._fn: Optional[Callable[..., Any]] = None
1116
+
1117
+ def _load(self) -> Callable[..., Any]:
1118
+ if self._fn is None:
1119
+ self._fn = _load_dotted(self._target_ref, VISION_CAPTION_TARGET_ENV, "vision caption")
1120
+ return self._fn
1121
+
1122
+ def available(self) -> bool:
1123
+ try:
1124
+ self._load()
1125
+ return True
1126
+ except EmbeddingUnavailable:
1127
+ return False
1128
+
1129
+ def caption(self, path: str, *, prompt: str = "") -> Optional[str]:
1130
+ try:
1131
+ text = self._load()(str(path), prompt or DEFAULT_CAPTION_PROMPT)
1132
+ except Exception:
1133
+ return None
1134
+ cleaned = str(text or "").strip()
1135
+ return cleaned or None
1136
+
1137
+ def health(self) -> Dict[str, Any]:
1138
+ try:
1139
+ self._load()
1140
+ return {"status": "ok", "detail": f"custom captioner {self._target_ref} loaded"}
1141
+ except EmbeddingUnavailable as exc:
1142
+ return {"status": "unavailable", "detail": str(exc)}
1143
+
1144
+
1145
+ def resolve_vision_captioner(
1146
+ provider: str = "", *, model: str = "", target: str = ""
1147
+ ) -> VisionCaptioner:
1148
+ """Build a captioner, or the null one that honestly captions nothing."""
1149
+ kind = str(provider or "").strip().lower()
1150
+ if kind in {"mlx", "mlx-vlm", "mlx_vlm"} and model:
1151
+ return MLXVisionCaptioner(model)
1152
+ if kind in {"custom", "custom-vlm"}:
1153
+ return CustomVisionCaptioner(target)
1154
+ return VisionCaptioner()
1155
+
1156
+
1157
+ def vision_caption_port(captioner: VisionCaptioner) -> Optional[Callable[[str], Optional[str]]]:
1158
+ """The caption seam Brain Core injects — ``None`` when no VLM is loaded."""
1159
+ return captioner.caption if captioner.available() else None
1160
+
1161
+
653
1162
  __all__ = [
1163
+ "DEFAULT_CAPTION_PROMPT",
1164
+ "DEFAULT_VISION_DIM",
1165
+ "VISION_CAPTION_TARGET_ENV",
1166
+ "VISION_PROVIDER_TYPES",
1167
+ "VISION_SPACES",
1168
+ "VISION_SPACE_IMAGE",
1169
+ "VISION_SPACE_SHARED",
1170
+ "VISION_TARGET_ENV",
1171
+ "CustomVisionCaptioner",
1172
+ "CustomVisionEmbeddingProvider",
654
1173
  "EmbeddingProvider",
655
1174
  "EmbeddingUnavailable",
1175
+ "MLXVisionCaptioner",
1176
+ "MLXVisionEmbeddingProvider",
1177
+ "ResolvedVisionEmbedder",
1178
+ "VisionCaptioner",
1179
+ "VisionEmbeddingProvider",
1180
+ "build_vision_provider",
1181
+ "resolve_vision_captioner",
1182
+ "resolve_vision_embedder",
1183
+ "vision_caption_port",
656
1184
  "HashEmbeddingProvider",
657
1185
  "MLXEmbeddingProvider",
658
1186
  "OllamaEmbeddingProvider",
@@ -13,7 +13,7 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
- LEGACY_COMPATIBILITY_VERSION = "11.0.1"
16
+ LEGACY_COMPATIBILITY_VERSION = "11.2.0"
17
17
 
18
18
 
19
19
  @dataclass(frozen=True)
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
- MARKETPLACE_VERSION = "11.0.1"
13
+ MARKETPLACE_VERSION = "11.2.0"
14
14
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
15
15
 
16
16