ltcai 11.5.1 → 11.5.2

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 (176) hide show
  1. package/README.md +85 -53
  2. package/docs/CHANGELOG.md +54 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/HYBRID_CLOUD_KG_STREAMING.md +8 -4
  6. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/WORKFLOW_DESIGNER.md +1 -1
  12. package/docs/architecture.md +4 -4
  13. package/docs/kg-schema.md +1 -1
  14. package/docs/v11.5.2_TIGHT_SHIP_PLAN.md +144 -0
  15. package/lattice_brain/__init__.py +1 -1
  16. package/lattice_brain/graph/proactive.py +1 -1
  17. package/lattice_brain/graph/projection/v2_schema.py +0 -28
  18. package/lattice_brain/graph/vector_index/base.py +0 -3
  19. package/lattice_brain/graph/vector_index/brute_force.py +0 -4
  20. package/lattice_brain/graph/vector_index/hnsw.py +0 -3
  21. package/lattice_brain/graph/vector_index/quantized.py +0 -3
  22. package/lattice_brain/ingestion_jobs.py +0 -3
  23. package/lattice_brain/memory.py +0 -27
  24. package/lattice_brain/portability/sharing.py +0 -4
  25. package/lattice_brain/quality.py +0 -49
  26. package/lattice_brain/runtime/agent_runtime.py +1 -1
  27. package/lattice_brain/runtime/hooks.py +0 -13
  28. package/lattice_brain/runtime/multi_agent.py +1 -1
  29. package/lattice_brain/sealed_box.py +0 -4
  30. package/latticeai/__init__.py +1 -1
  31. package/latticeai/api/admin.py +12 -12
  32. package/latticeai/api/auth.py +25 -2
  33. package/latticeai/api/chat.py +3 -3
  34. package/latticeai/api/chat_agent_http.py +3 -12
  35. package/latticeai/api/chat_helpers.py +10 -13
  36. package/latticeai/api/computer_use.py +31 -42
  37. package/latticeai/api/health.py +21 -1
  38. package/latticeai/api/local_files.py +14 -0
  39. package/latticeai/api/permissions.py +38 -12
  40. package/latticeai/api/search.py +24 -0
  41. package/latticeai/api/static_routes.py +4 -1
  42. package/latticeai/cli/runtime.py +7 -3
  43. package/latticeai/core/config.py +47 -3
  44. package/latticeai/core/csrf.py +28 -2
  45. package/latticeai/core/embedding_providers/__init__.py +3 -5
  46. package/latticeai/core/embedding_providers/base.py +1 -1
  47. package/latticeai/core/embedding_providers/text.py +1 -1
  48. package/latticeai/core/enterprise.py +1 -4
  49. package/latticeai/core/http_origin.py +146 -0
  50. package/latticeai/core/invitations.py +3 -2
  51. package/latticeai/core/io_utils.py +2 -11
  52. package/latticeai/core/legacy_compatibility.py +1 -1
  53. package/latticeai/core/marketplace.py +1 -1
  54. package/latticeai/core/messages.py +5 -0
  55. package/latticeai/core/model_compat.py +2 -8
  56. package/latticeai/core/module_probe.py +37 -0
  57. package/latticeai/core/run_explain.py +5 -2
  58. package/latticeai/core/run_store.py +2 -2
  59. package/latticeai/core/security.py +13 -0
  60. package/latticeai/core/sessions.py +3 -2
  61. package/latticeai/core/sse.py +25 -0
  62. package/latticeai/core/users.py +0 -9
  63. package/latticeai/core/workspace_graph_trace.py +2 -8
  64. package/latticeai/core/workspace_os.py +0 -10
  65. package/latticeai/core/workspace_os_constants.py +1 -1
  66. package/latticeai/core/workspace_os_utils.py +34 -0
  67. package/latticeai/core/workspace_review_items.py +2 -8
  68. package/latticeai/core/workspace_runs.py +2 -8
  69. package/latticeai/core/workspace_skills.py +2 -7
  70. package/latticeai/models/router/documents.py +0 -35
  71. package/latticeai/models/router/registry.py +0 -4
  72. package/latticeai/runtime/access_runtime.py +16 -4
  73. package/latticeai/runtime/build_phases/features.py +2 -0
  74. package/latticeai/runtime/build_phases/web.py +2 -0
  75. package/latticeai/runtime/feature_toggle_wiring.py +12 -18
  76. package/latticeai/runtime/network_boundary_wiring.py +8 -19
  77. package/latticeai/runtime/permission_mode_wiring.py +6 -13
  78. package/latticeai/runtime/router_registration.py +4 -0
  79. package/latticeai/runtime/runtime_context.py +1 -13
  80. package/latticeai/runtime/service_singletons.py +55 -0
  81. package/latticeai/services/architecture_readiness.py +1 -1
  82. package/latticeai/services/brain_intelligence/proposals.py +0 -5
  83. package/latticeai/services/change_proposals.py +2 -36
  84. package/latticeai/services/chronicle.py +4 -6
  85. package/latticeai/services/command_center.py +4 -4
  86. package/latticeai/services/evidence_actions.py +5 -2
  87. package/latticeai/services/hybrid_chat.py +2 -2
  88. package/latticeai/services/hybrid_policy.py +8 -57
  89. package/latticeai/services/mode_store.py +132 -0
  90. package/latticeai/services/model_capability_registry.py +0 -15
  91. package/latticeai/services/model_catalog.py +0 -7
  92. package/latticeai/services/model_loading.py +3 -2
  93. package/latticeai/services/model_runtime/__init__.py +0 -57
  94. package/latticeai/services/model_runtime/engines.py +27 -128
  95. package/latticeai/services/model_runtime/loading.py +2 -2
  96. package/latticeai/services/network_boundary_service.py +7 -44
  97. package/latticeai/services/permission_mode_service.py +8 -56
  98. package/latticeai/services/product_readiness.py +1 -1
  99. package/latticeai/services/setup_detection.py +67 -1
  100. package/latticeai/services/tool_dispatch.py +0 -4
  101. package/latticeai/services/upload_service.py +7 -20
  102. package/latticeai/services/workspace_service.py +0 -6
  103. package/latticeai/setup/auto_setup.py +15 -26
  104. package/latticeai/setup/wizard/catalog.py +5 -2
  105. package/latticeai/setup/wizard/detect.py +6 -27
  106. package/latticeai/setup/wizard/paths.py +2 -5
  107. package/latticeai/setup/wizard/plans.py +2 -2
  108. package/package.json +2 -4
  109. package/scripts/bump_version.py +14 -1
  110. package/scripts/check_current_release_docs.mjs +1 -1
  111. package/scripts/check_legacy_debt.mjs +1 -1
  112. package/scripts/generate_agent_loop_fixtures.py +87 -0
  113. package/scripts/generate_rust_parity_fixtures.py +3 -95
  114. package/scripts/parity_fixture_corpus_context.py +162 -0
  115. package/scripts/release_screen_claims.json +10 -0
  116. package/src-tauri/Cargo.lock +7 -7
  117. package/src-tauri/Cargo.toml +1 -1
  118. package/src-tauri/src/backend.rs +76 -2
  119. package/src-tauri/src/main.rs +13 -3
  120. package/src-tauri/tauri.conf.json +2 -2
  121. package/static/app/asset-manifest.json +40 -40
  122. package/static/app/assets/{Act-Drs_jd-O.js → Act-DcQizkl1.js} +1 -1
  123. package/static/app/assets/{AdminConsole-BqNx5oF6.js → AdminConsole-cf4npybT.js} +1 -1
  124. package/static/app/assets/{Brain-C7bdNmz-.js → Brain-3VCSHFcn.js} +1 -1
  125. package/static/app/assets/{BrainHome-DcTnINcI.js → BrainHome-Qm8eaztx.js} +1 -1
  126. package/static/app/assets/{BrainSignals-Df1MRIPD.js → BrainSignals-DS9BtKOW.js} +1 -1
  127. package/static/app/assets/{Capture-Dgsxvnbx.js → Capture-DiQ219jW.js} +1 -1
  128. package/static/app/assets/{Chronicle-BrkTxJK1.js → Chronicle-BGvuAchH.js} +1 -1
  129. package/static/app/assets/{CommandPalette-DDziw7Oj.js → CommandPalette-Bqhm0Urn.js} +1 -1
  130. package/static/app/assets/{Library-CYxeuraA.js → Library-BV6NnF0a.js} +1 -1
  131. package/static/app/assets/{LivingBrain-CNp6mvCm.js → LivingBrain-GzenJchP.js} +1 -1
  132. package/static/app/assets/{ProductFlow-BVsowu3Z.js → ProductFlow-DEP6-vML.js} +1 -1
  133. package/static/app/assets/{ReviewCard-D1LbOfJS.js → ReviewCard-CNZ7XjWG.js} +1 -1
  134. package/static/app/assets/{System-DcRAq7wj.js → System-CieofHQa.js} +1 -1
  135. package/static/app/assets/arrow-left-kfsrk0mv.js +1 -0
  136. package/static/app/assets/{bot-BK2xQ9mN.js → bot-B_K1Tdmw.js} +1 -1
  137. package/static/app/assets/{brain-BZNztFBb.js → brain-DWyaV1L1.js} +1 -1
  138. package/static/app/assets/{button-CjueubVZ.js → button-aTn4s84A.js} +1 -1
  139. package/static/app/assets/circle-check-qqLug9nU.js +1 -0
  140. package/static/app/assets/{circle-pause-HRjSmpyY.js → circle-pause-xKgeGXkT.js} +1 -1
  141. package/static/app/assets/{circle-play-CMcKDVwu.js → circle-play-DkT6tYPX.js} +1 -1
  142. package/static/app/assets/{cpu-B_MyTfYr.js → cpu-85xYObUC.js} +1 -1
  143. package/static/app/assets/{download-CTy0EByV.js → download-B5Fm7YXo.js} +1 -1
  144. package/static/app/assets/{folder-open-DF6bLhTg.js → folder-open-kk2Xa52u.js} +1 -1
  145. package/static/app/assets/{hard-drive-CpWHG77C.js → hard-drive-DkA3zBW_.js} +1 -1
  146. package/static/app/assets/{index-Dd6abJHX.js → index-BMPdTmlY.js} +3 -3
  147. package/static/app/assets/{input-bVgIRN3s.js → input-B0nRf2jO.js} +1 -1
  148. package/static/app/assets/{link-2-bg6CG5Vk.js → link-2-Dwb4gnTc.js} +1 -1
  149. package/static/app/assets/{permissionCopy-DFTf1HDZ.js → permissionCopy-CQDUBrOZ.js} +1 -1
  150. package/static/app/assets/{primitives-D7D-sQ3T.js → primitives-SNp0LRJz.js} +1 -1
  151. package/static/app/assets/search-BcHqkjoy.js +1 -0
  152. package/static/app/assets/{share-2-BLzo7U4L.js → share-2-BsrxFglO.js} +1 -1
  153. package/static/app/assets/{shield-alert-BmngqnsJ.js → shield-alert-5BStfp2_.js} +1 -1
  154. package/static/app/assets/{textarea-DOSlAQQ3.js → textarea-Cg8IUA-k.js} +1 -1
  155. package/static/app/assets/{useFocusTrap-pZhgeee4.js → useFocusTrap-CYKvE46M.js} +1 -1
  156. package/static/app/assets/{useMutation-BMDwNk4I.js → useMutation-CSn9t1op.js} +1 -1
  157. package/static/app/assets/{useQuery-CSjKttRo.js → useQuery-CY2OI2uy.js} +1 -1
  158. package/static/app/assets/{utils-D3u_yv7B.js → utils-Ddol2RWD.js} +1 -1
  159. package/static/app/assets/{workspace-H3bMjYXC.js → workspace-BqDwOz_p.js} +1 -1
  160. package/static/app/index.html +3 -3
  161. package/static/sw.js +1 -1
  162. package/desktop/electron/README.md +0 -9
  163. package/desktop/electron/main.cjs +0 -58
  164. package/desktop/electron/preload.cjs +0 -5
  165. package/latticeai/core/graph_curator.py +0 -11
  166. package/latticeai/core/hooks.py +0 -11
  167. package/latticeai/core/local_embeddings.py +0 -104
  168. package/latticeai/core/multi_agent.py +0 -11
  169. package/latticeai/core/workflow_engine.py +0 -11
  170. package/latticeai/services/ingestion.py +0 -11
  171. package/latticeai/services/kg_portability.py +0 -11
  172. package/latticeai/services/multimodal_streaming.py +0 -129
  173. package/scripts/measure_brain_home_fill.mjs +0 -141
  174. package/static/app/assets/arrow-left-BHYmOTWU.js +0 -1
  175. package/static/app/assets/circle-check-CLeWJr67.js +0 -1
  176. package/static/app/assets/search-BgSVX6NG.js +0 -1
@@ -83,6 +83,7 @@ def build_auth_admin_security_router_bundle(
83
83
  secure_cookies: bool,
84
84
  invite_authorized: Any,
85
85
  ensure_identity: Any,
86
+ sso_default_redirect_uri: str,
86
87
  create_admin_router: Any,
87
88
  require_admin: Any,
88
89
  get_history: Any,
@@ -138,6 +139,7 @@ def build_auth_admin_security_router_bundle(
138
139
  invite_gate_enabled=invite_gate_enabled,
139
140
  invite_authorized=invite_authorized,
140
141
  ensure_identity=ensure_identity,
142
+ default_redirect_uri=sso_default_redirect_uri,
141
143
  )
142
144
 
143
145
  def graph_stats_safe() -> dict[str, Any]:
@@ -376,6 +378,7 @@ def register_health_and_model_routers(
376
378
  engine_status: Any,
377
379
  get_current_user: Any,
378
380
  require_auth: bool,
381
+ externally_reachable: bool,
379
382
  app_version: str,
380
383
  app_mode: str,
381
384
  create_models_router: Any,
@@ -411,6 +414,7 @@ def register_health_and_model_routers(
411
414
  engine_status=engine_status,
412
415
  get_current_user=get_current_user,
413
416
  require_auth=require_auth,
417
+ externally_reachable=externally_reachable,
414
418
  app_version=app_version,
415
419
  app_mode=app_mode,
416
420
  ),
@@ -25,7 +25,7 @@ Two properties keep it honest:
25
25
 
26
26
  from __future__ import annotations
27
27
 
28
- from typing import Any, Dict, List, Set
28
+ from typing import Any, Dict, Set
29
29
 
30
30
 
31
31
  class RuntimeContext:
@@ -257,21 +257,9 @@ class RuntimeContext:
257
257
  f"RuntimeContext.{name} was read before it was built{hint}"
258
258
  ) from None
259
259
 
260
- @property
261
- def produced_by(self) -> Dict[str, str]:
262
- """Attribute name → the phase that published it, for the order test."""
263
- return dict(self._produced)
264
-
265
260
  def names(self) -> Set[str]:
266
261
  return set(self._produced)
267
262
 
268
- def phases_run(self) -> List[str]:
269
- seen: List[str] = []
270
- for phase in self._produced.values():
271
- if phase not in seen:
272
- seen.append(phase)
273
- return seen
274
-
275
263
  def __repr__(self) -> str: # pragma: no cover - debugging aid
276
264
  return f"<RuntimeContext phase={self._phase!r} names={len(self._produced)}>"
277
265
 
@@ -0,0 +1,55 @@
1
+ """The rebinding half of the process-wide service singletons.
2
+
3
+ Four wiring modules create one service each — the permission dial, the network
4
+ boundary, the hybrid policy, the feature switchboard — and all four had the
5
+ same problem and the same four-line answer to it: a lazy first caller (a tool
6
+ dispatch, a feature gate) can ask for the service *before* routers know the
7
+ configured data dir or the audit sink, and if the later explicit call were
8
+ allowed to be a no-op the service would stay pinned to the fallback path with
9
+ its audit events going nowhere.
10
+
11
+ So explicit arguments rebind rather than being dropped. That rule was written
12
+ out four times; it lives here once, because "the second caller silently loses"
13
+ is precisely the kind of divergence that produces a second, invisible copy of
14
+ the user's data and no failing test.
15
+
16
+ The singleton *slot* deliberately stays a module global in each wiring module:
17
+ the modules are the unit of process state, and the suite resets them by
18
+ assigning ``_SHARED = None``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path
24
+ from typing import Any, Callable, Optional, TypeVar
25
+
26
+ from latticeai.core.config import default_data_dir
27
+
28
+ ServiceT = TypeVar("ServiceT")
29
+
30
+ __all__ = ["singleton_data_dir", "rebind_singleton"]
31
+
32
+
33
+ def singleton_data_dir(data_dir: Optional[Path]) -> Path:
34
+ """Where a *first* lazy caller should put the store."""
35
+ return Path(data_dir) if data_dir is not None else default_data_dir()
36
+
37
+
38
+ def rebind_singleton(
39
+ service: ServiceT,
40
+ *,
41
+ data_dir: Optional[Path] = None,
42
+ audit: Optional[Callable[..., None]] = None,
43
+ ) -> ServiceT:
44
+ """Apply explicit arguments to an already-created singleton, then return it.
45
+
46
+ ``None`` means "the caller did not say", never "unset it" — a router that
47
+ mounts without an audit sink must not silence a sink an earlier caller
48
+ already supplied.
49
+ """
50
+ rebindable: Any = service
51
+ if data_dir is not None:
52
+ rebindable.rebind_data_dir(Path(data_dir))
53
+ if audit is not None:
54
+ rebindable.rebind_audit(audit)
55
+ return service
@@ -16,7 +16,7 @@ from typing import Any, Dict, List
16
16
 
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
- ARCHITECTURE_VERSION_TARGET = "11.5.1"
19
+ ARCHITECTURE_VERSION_TARGET = "11.5.2"
20
20
 
21
21
  PREFERRED_REFACTORING_ORDER = [
22
22
  "agent-runtime",
@@ -31,11 +31,6 @@ class BrainProposalsMixin(_Core):
31
31
  # is absent they report ``available: False`` rather than falling back to a
32
32
  # direct write.
33
33
 
34
- def attach_review_queue(self, review_queue: Any) -> None:
35
- """Bind the review queue proposals are written to (composition root)."""
36
- self._review_queue_service = review_queue
37
- self._synthesizer = None
38
-
39
34
  def _review_queue(self) -> Any:
40
35
  """The queue to propose into, or ``None`` when there is none.
41
36
 
@@ -21,7 +21,6 @@ change audit log. The classification itself lives in
21
21
  from __future__ import annotations
22
22
 
23
23
  import difflib
24
- import hashlib
25
24
  import logging
26
25
  import os
27
26
  import tempfile
@@ -30,6 +29,7 @@ from pathlib import Path
30
29
  from typing import Any, Callable, Dict, List, Optional, Tuple
31
30
 
32
31
  from latticeai.core.quiet import quiet
32
+ from latticeai.core.security import sha256_hex
33
33
  from latticeai.core.tool_governor import classify_tool_call
34
34
  from latticeai.core.workspace_reorganization import (
35
35
  REORG_KIND,
@@ -90,7 +90,7 @@ class ProposalConflictError(ValueError):
90
90
 
91
91
 
92
92
  def _sha256_text(content: str) -> str:
93
- return hashlib.sha256(content.encode("utf-8")).hexdigest()
93
+ return sha256_hex(content)
94
94
 
95
95
 
96
96
  def _unified_diff(before: str, after: str, path: str) -> List[str]:
@@ -272,40 +272,6 @@ class ChangeProposalService:
272
272
  )
273
273
  return item
274
274
 
275
- def propose_file_delete(
276
- self,
277
- *,
278
- path: str,
279
- proposed_by: str = "agent",
280
- reason: str = "",
281
- user_email: Optional[str] = None,
282
- workspace_id: Optional[str] = None,
283
- ) -> Dict[str, Any]:
284
- base_exists, before = self._snapshot(path)
285
- item = self._review_queue.create(
286
- title=f"파일 삭제 제안: {path}",
287
- summary=reason or "기존 파일을 삭제하는 작업이라 검토 후 적용됩니다.",
288
- source="change_proposal",
289
- kind="file_delete",
290
- payload={
291
- "path": path,
292
- "diff": _unified_diff(before, "", path),
293
- "tier": "large",
294
- "before_bytes": len(before.encode("utf-8")),
295
- "after_bytes": 0,
296
- "base_exists": base_exists,
297
- "base_sha256": _sha256_text(before) if base_exists else "",
298
- },
299
- provenance={"proposed_by": proposed_by, "reason": reason},
300
- user_email=user_email,
301
- workspace_id=workspace_id,
302
- )
303
- self._audit(
304
- "change_proposal_created", user_email=user_email,
305
- proposal_id=item.get("id"), path=path, kind="file_delete", tier="large",
306
- )
307
- return item
308
-
309
275
  def propose_reorganization(
310
276
  self,
311
277
  *,
@@ -48,6 +48,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
48
48
 
49
49
  from latticeai.core.timeutil import parse_iso
50
50
  from latticeai.core.timezones import get_timezone
51
+ from latticeai.core.workspace_os_utils import graph_scope_kwargs
51
52
 
52
53
  LOGGER = logging.getLogger(__name__)
53
54
 
@@ -249,12 +250,9 @@ def _empty_lanes() -> Dict[str, int]:
249
250
  return {lane: 0 for lane in _LANES}
250
251
 
251
252
 
252
- def _scope_kwargs(workspace_id: Optional[str]) -> Dict[str, Any]:
253
- """The scoped-read kwargs the graph store expects (house pattern)."""
254
- return {
255
- "allowed_workspaces": {workspace_id} if workspace_id is not None else None,
256
- "include_legacy_global": workspace_id is None,
257
- }
253
+
254
+ #: Historical module-local name for the shared rule.
255
+ _scope_kwargs = graph_scope_kwargs
258
256
 
259
257
 
260
258
  def _on_day(rows: Sequence[Any], day: str) -> List[Any]:
@@ -28,6 +28,7 @@ from typing import Any, Dict, List, Optional
28
28
 
29
29
  from latticeai.core.timeutil import local_now, parse_iso
30
30
  from latticeai.core.timeutil import now_iso as _now
31
+ from latticeai.core.workspace_os_utils import graph_scope_kwargs
31
32
 
32
33
  LOGGER = logging.getLogger(__name__)
33
34
 
@@ -48,6 +49,8 @@ def _clip(text: Any, limit: int = 160) -> str:
48
49
  return value[:limit]
49
50
 
50
51
 
52
+
53
+
51
54
  class CommandCenterService:
52
55
  def __init__(
53
56
  self,
@@ -73,10 +76,7 @@ class CommandCenterService:
73
76
  # ── scoped reads ─────────────────────────────────────────────────────
74
77
 
75
78
  def _scope_kwargs(self, workspace_id: Optional[str]) -> Dict[str, Any]:
76
- return {
77
- "allowed_workspaces": {workspace_id} if workspace_id is not None else None,
78
- "include_legacy_global": workspace_id is None,
79
- }
79
+ return graph_scope_kwargs(workspace_id)
80
80
 
81
81
  def _history(
82
82
  self, *, user_email: Optional[str], workspace_id: Optional[str], limit: int
@@ -28,6 +28,8 @@ import logging
28
28
  import re
29
29
  from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
30
30
 
31
+ from latticeai.core.messages import bilingual
32
+
31
33
  LOGGER = logging.getLogger(__name__)
32
34
 
33
35
  __all__ = ["EvidenceActionService", "EVIDENCE_ACTIONS"]
@@ -38,8 +40,9 @@ _EXCERPT_CHARS = 600
38
40
  _MAX_SOURCES = 8
39
41
 
40
42
 
41
- def _phrase(ko: str, en: str) -> Dict[str, str]:
42
- return {"ko": ko, "en": en}
43
+
44
+ #: Historical module-local name for the shared phrase-pair helper.
45
+ _phrase = bilingual
43
46
 
44
47
 
45
48
  # The action catalog is closed and deterministic — the UI renders exactly
@@ -13,7 +13,6 @@ auto-commit — so a headless caller stages nothing rather than writing.
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
- import json
17
16
  import logging
18
17
  from typing import Any, AsyncIterator, Dict, Optional
19
18
 
@@ -21,6 +20,7 @@ from latticeai.core.network_boundary import (
21
20
  NetworkBoundaryMode,
22
21
  normalize_network_mode,
23
22
  )
23
+ from latticeai.core.sse import sse_frame
24
24
  from latticeai.services.cloud_egress_audit import record_cloud_egress
25
25
  from latticeai.services.cloud_extraction import plan_kg_expansion_rich
26
26
  from latticeai.services.cloud_streaming import (
@@ -36,7 +36,7 @@ logger = logging.getLogger(__name__)
36
36
 
37
37
 
38
38
  def _sse(data: Dict[str, Any]) -> str:
39
- return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
39
+ return sse_frame(None, data)
40
40
 
41
41
 
42
42
  def _scope_key(user_email: Optional[str], workspace_id: Optional[str]) -> str:
@@ -12,16 +12,13 @@ Persisted under the data dir, scoped like NetworkBoundaryService
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- import json
16
- import threading
17
- from pathlib import Path
18
- from typing import Any, Callable, Dict, List, Optional, Set
15
+ from typing import Any, Dict, List, Optional, Set
19
16
 
20
- from latticeai.core.io_utils import atomic_write_json
21
17
  from latticeai.core.network_boundary import (
22
18
  HARD_BLOCK_METADATA_FLAGS,
23
19
  HARD_BLOCK_NODE_TYPES,
24
20
  )
21
+ from latticeai.services.mode_store import JsonBackedModeService
25
22
 
26
23
  DEFAULT_BLOCKED_TYPES: List[str] = []
27
24
  DEFAULT_BLOCKED_FLAGS: List[str] = sorted(HARD_BLOCK_METADATA_FLAGS)
@@ -39,51 +36,15 @@ def _default_policy() -> Dict[str, Any]:
39
36
  }
40
37
 
41
38
 
42
- class HybridPolicyService:
39
+ class HybridPolicyService(JsonBackedModeService[Dict[str, Any]]):
43
40
  """Load / save hybrid cloud policy."""
44
41
 
45
- def __init__(
46
- self,
47
- *,
48
- data_dir: Path,
49
- audit: Optional[Callable[..., None]] = None,
50
- ) -> None:
51
- self._path = Path(data_dir) / "hybrid_policy.json"
52
- self._audit = audit or (lambda *a, **kw: None)
53
- self._lock = threading.Lock()
54
-
55
- def rebind_data_dir(self, data_dir: Path) -> None:
56
- with self._lock:
57
- self._path = Path(data_dir) / "hybrid_policy.json"
42
+ FILENAME = "hybrid_policy.json"
58
43
 
59
- def rebind_audit(self, audit: Callable[..., None]) -> None:
60
- with self._lock:
61
- self._audit = audit
44
+ def _default_entry(self) -> Any:
45
+ return _default_policy()
62
46
 
63
- def _read(self) -> Dict[str, Any]:
64
- base = {
65
- "default": _default_policy(),
66
- "users": {},
67
- "workspaces": {},
68
- }
69
- if not self._path.exists():
70
- return base
71
- try:
72
- data = json.loads(self._path.read_text(encoding="utf-8"))
73
- except Exception:
74
- return base
75
- if not isinstance(data, dict):
76
- return base
77
- data.setdefault("default", _default_policy())
78
- data.setdefault("users", {})
79
- data.setdefault("workspaces", {})
80
- return data
81
-
82
- def _write(self, data: Dict[str, Any]) -> None:
83
- self._path.parent.mkdir(parents=True, exist_ok=True)
84
- atomic_write_json(self._path, data)
85
-
86
- def _resolve_raw(
47
+ def _resolve_from(
87
48
  self,
88
49
  data: Dict[str, Any],
89
50
  *,
@@ -116,16 +77,6 @@ class HybridPolicyService:
116
77
  policy["min_extraction_confidence"] = 0.55
117
78
  return policy
118
79
 
119
- def resolve(
120
- self,
121
- *,
122
- user_email: Optional[str] = None,
123
- workspace_id: Optional[str] = None,
124
- ) -> Dict[str, Any]:
125
- with self._lock:
126
- data = self._read()
127
- return self._resolve_raw(data, user_email=user_email, workspace_id=workspace_id)
128
-
129
80
  def set_policy(
130
81
  self,
131
82
  patch: Dict[str, Any],
@@ -144,7 +95,7 @@ class HybridPolicyService:
144
95
  clean = {k: v for k, v in (patch or {}).items() if k in allowed}
145
96
  with self._lock:
146
97
  data = self._read()
147
- previous = self._resolve_raw(
98
+ previous = self._resolve_from(
148
99
  data, user_email=user_email, workspace_id=workspace_id
149
100
  )
150
101
  if workspace_id:
@@ -0,0 +1,132 @@
1
+ """The shared storage half of the scoped preference dials.
2
+
3
+ ``PermissionModeService``, ``NetworkBoundaryService`` and ``HybridPolicyService``
4
+ are three different policies over one identical store: a JSON file under the
5
+ data dir holding ``{"default": …, "users": {…}, "workspaces": {…}}``, guarded by
6
+ a lock, rebindable after lazy construction, and read defensively enough that a
7
+ truncated or hand-edited file degrades to defaults instead of taking the app
8
+ down.
9
+
10
+ That half was written three times, character for character. Three copies of a
11
+ "corrupt file falls back to defaults" rule is three chances for one of them to
12
+ grow an exception path the others do not have — and the symptom would be one
13
+ dial silently forgetting a user's choice while the other two remember. The
14
+ storage lives here once; each service supplies only what actually differs:
15
+ the file name, what an unset ``default`` means, and how a scope resolves.
16
+
17
+ Deliberately *not* shared: ``set_mode``/``set_policy``. They look similar but
18
+ their write shapes differ (a scalar mode replaces, a policy patch merges) and
19
+ their audit events carry different payloads, so folding them together would
20
+ mean a parameterised method with more branches than the two bodies it replaced.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import threading
27
+ from pathlib import Path
28
+ from typing import Any, Callable, ClassVar, Dict, Generic, Optional, TypeVar
29
+
30
+ from latticeai.core.io_utils import atomic_write_json
31
+
32
+ #: What ``resolve`` hands back — an enum member for the mode dials, a plain
33
+ #: dict for the policy service.
34
+ ResolvedT = TypeVar("ResolvedT")
35
+
36
+ __all__ = ["JsonBackedModeService"]
37
+
38
+
39
+ class JsonBackedModeService(Generic[ResolvedT]):
40
+ """A lock-guarded, scope-aware JSON preference file.
41
+
42
+ Subclasses set :attr:`FILENAME` and implement :meth:`_default_entry` and
43
+ :meth:`_resolve_from`.
44
+ """
45
+
46
+ #: File name under the data dir. Subclasses must set this.
47
+ FILENAME: ClassVar[str] = ""
48
+
49
+ def __init__(
50
+ self,
51
+ *,
52
+ data_dir: Path,
53
+ audit: Optional[Callable[..., None]] = None,
54
+ ) -> None:
55
+ self._path = Path(data_dir) / self.FILENAME
56
+ self._audit = audit or (lambda *a, **kw: None)
57
+ self._lock = threading.Lock()
58
+
59
+ # ── rebinding ────────────────────────────────────────────────────────────
60
+ def rebind_data_dir(self, data_dir: Path) -> None:
61
+ """Point the store at the app's real data dir.
62
+
63
+ The wiring may instantiate a service lazily before routers know the
64
+ configured data dir; rebinding keeps one file of record instead of
65
+ stranding writes under the fallback path.
66
+ """
67
+ with self._lock:
68
+ self._path = Path(data_dir) / self.FILENAME
69
+
70
+ def rebind_audit(self, audit: Callable[..., None]) -> None:
71
+ """Attach the real audit sink once app wiring provides one."""
72
+ with self._lock:
73
+ self._audit = audit
74
+
75
+ # ── what each subclass must decide ───────────────────────────────────────
76
+ def _default_entry(self) -> Any:
77
+ """The ``default`` bucket's value when the file does not say.
78
+
79
+ Called fresh at every use because the policy service's answer is a
80
+ mutable dict; a shared instance would let one caller's edit leak into
81
+ the next caller's defaults.
82
+ """
83
+ raise NotImplementedError
84
+
85
+ def _resolve_from(
86
+ self,
87
+ data: Dict[str, Any],
88
+ *,
89
+ user_email: Optional[str],
90
+ workspace_id: Optional[str],
91
+ ) -> ResolvedT:
92
+ """Apply this dial's scope precedence to already-loaded ``data``.
93
+
94
+ Pure over ``data`` and lock-free, so holders of ``_lock`` can reuse it
95
+ without re-entering a non-reentrant lock.
96
+ """
97
+ raise NotImplementedError
98
+
99
+ # ── storage ──────────────────────────────────────────────────────────────
100
+ def _empty(self) -> Dict[str, Any]:
101
+ return {"default": self._default_entry(), "users": {}, "workspaces": {}}
102
+
103
+ def _read(self) -> Dict[str, Any]:
104
+ if not self._path.exists():
105
+ return self._empty()
106
+ try:
107
+ data = json.loads(self._path.read_text(encoding="utf-8"))
108
+ except Exception:
109
+ return self._empty()
110
+ if not isinstance(data, dict):
111
+ return self._empty()
112
+ data.setdefault("default", self._default_entry())
113
+ data.setdefault("users", {})
114
+ data.setdefault("workspaces", {})
115
+ return data
116
+
117
+ def _write(self, data: Dict[str, Any]) -> None:
118
+ self._path.parent.mkdir(parents=True, exist_ok=True)
119
+ atomic_write_json(self._path, data)
120
+
121
+ # ── reading ──────────────────────────────────────────────────────────────
122
+ def resolve(
123
+ self,
124
+ *,
125
+ user_email: Optional[str] = None,
126
+ workspace_id: Optional[str] = None,
127
+ ) -> ResolvedT:
128
+ with self._lock:
129
+ data = self._read()
130
+ return self._resolve_from(
131
+ data, user_email=user_email, workspace_id=workspace_id,
132
+ )
@@ -636,11 +636,6 @@ def get_all_capabilities() -> List[ModelCapability]:
636
636
  return [*_REGISTRY, *_LEGACY_REGISTRY]
637
637
 
638
638
 
639
- def get_recommended_capabilities() -> List[ModelCapability]:
640
- """Current-generation entries: catalog, download and recommendation input."""
641
- return list(_REGISTRY)
642
-
643
-
644
639
  def get_legacy_capabilities() -> List[ModelCapability]:
645
640
  """Recognised-only entries: never offered, never recommended, still named."""
646
641
  return list(_LEGACY_REGISTRY)
@@ -663,12 +658,6 @@ def is_recognized_model(model_id: str) -> bool:
663
658
  return get_capability(model_id) is not None
664
659
 
665
660
 
666
- def is_recommended_model(model_id: str) -> bool:
667
- """True only for current-generation entries — the download/offer gate."""
668
- cap = get_capability(model_id)
669
- return cap is not None and cap.lifecycle == RECOMMENDED
670
-
671
-
672
661
  def build_engine_model_catalog() -> Dict[str, List[Dict[str, Any]]]:
673
662
  """Return legacy ENGINE_MODEL_CATALOG shape, enriched with rich fields.
674
663
 
@@ -717,7 +706,3 @@ def get_verified_models() -> List[Dict[str, Any]]:
717
706
  if c.verification.hf_exists and c.verification.has_config and c.verification.has_tokenizer
718
707
  ]
719
708
 
720
-
721
- # Back-compat: expose a simple list mirroring the old top-level for mlx.
722
- # Recommended entries only — same reasoning as build_engine_model_catalog.
723
- LOCAL_MLX_MODELS = [c.to_legacy_dict() for c in _REGISTRY if "local_mlx" in c.provider_hints]
@@ -15,10 +15,6 @@ import re
15
15
  import sys
16
16
  from typing import Any, Dict, List, Optional
17
17
 
18
- from latticeai.services.model_capability_registry import (
19
- LOCAL_MLX_MODELS as _LOCAL_MLX_MODELS,
20
- )
21
-
22
18
  # 5.2.0: Delegate catalog data to the structured capability registry (rich + verified).
23
19
  # This keeps backward compat for every `from ...model_catalog import ENGINE_MODEL_CATALOG`.
24
20
  from latticeai.services.model_capability_registry import (
@@ -178,9 +174,6 @@ get_all_capabilities = _get_all_capabilities
178
174
  get_capability = _get_capability
179
175
  get_verified_models = _get_verified_models
180
176
 
181
- # Convenience re-export for tests / places that did `from ...model_catalog import LOCAL_MLX_MODELS`
182
- LOCAL_MLX_MODELS = _LOCAL_MLX_MODELS # type: ignore[name-defined]
183
-
184
177
  _VERSIONED_MODEL_PATTERNS = (
185
178
  ("gemma", re.compile(r"\bgemma[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
186
179
  ("qwen", re.compile(r"\bqwen[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
@@ -6,7 +6,6 @@ Re-exports will be added in model_runtime for compat.
6
6
  from __future__ import annotations
7
7
 
8
8
  import asyncio
9
- import json
10
9
  import logging
11
10
  import queue
12
11
  import subprocess
@@ -15,6 +14,8 @@ from functools import partial
15
14
  from pathlib import Path
16
15
  from typing import Any, AsyncIterator, Dict, Optional
17
16
 
17
+ from latticeai.core.sse import sse_frame
18
+
18
19
  from .model_errors import ModelRuntimeError
19
20
 
20
21
 
@@ -243,7 +244,7 @@ async def prepare_and_load_model(
243
244
 
244
245
 
245
246
  def sse_event(event: str, data: Dict[str, object]) -> str:
246
- return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
247
+ return sse_frame(event, data)
247
248
 
248
249
 
249
250
  async def prepare_and_load_model_stream(