ltcai 11.5.0 → 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 (182) hide show
  1. package/README.md +86 -47
  2. package/docs/CHANGELOG.md +76 -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.1_RUST_FULL_LOOP_PLAN.md +85 -0
  15. package/docs/v11.5.2_TIGHT_SHIP_PLAN.md +144 -0
  16. package/lattice_brain/__init__.py +1 -1
  17. package/lattice_brain/graph/proactive.py +1 -1
  18. package/lattice_brain/graph/projection/v2_schema.py +0 -28
  19. package/lattice_brain/graph/vector_index/base.py +0 -3
  20. package/lattice_brain/graph/vector_index/brute_force.py +0 -4
  21. package/lattice_brain/graph/vector_index/hnsw.py +0 -3
  22. package/lattice_brain/graph/vector_index/quantized.py +0 -3
  23. package/lattice_brain/ingestion_jobs.py +0 -3
  24. package/lattice_brain/memory.py +0 -27
  25. package/lattice_brain/portability/sharing.py +0 -4
  26. package/lattice_brain/quality.py +0 -49
  27. package/lattice_brain/runtime/agent_runtime.py +1 -1
  28. package/lattice_brain/runtime/hooks.py +0 -13
  29. package/lattice_brain/runtime/multi_agent.py +1 -1
  30. package/lattice_brain/sealed_box.py +0 -4
  31. package/latticeai/__init__.py +1 -1
  32. package/latticeai/api/admin.py +12 -12
  33. package/latticeai/api/agent_worker_seam.py +389 -0
  34. package/latticeai/api/auth.py +25 -2
  35. package/latticeai/api/chat.py +3 -3
  36. package/latticeai/api/chat_agent_http.py +3 -12
  37. package/latticeai/api/chat_helpers.py +10 -13
  38. package/latticeai/api/computer_use.py +31 -42
  39. package/latticeai/api/health.py +21 -1
  40. package/latticeai/api/local_files.py +14 -0
  41. package/latticeai/api/permissions.py +38 -12
  42. package/latticeai/api/search.py +24 -0
  43. package/latticeai/api/static_routes.py +4 -1
  44. package/latticeai/cli/runtime.py +7 -3
  45. package/latticeai/core/config.py +47 -3
  46. package/latticeai/core/csrf.py +28 -2
  47. package/latticeai/core/embedding_providers/__init__.py +3 -5
  48. package/latticeai/core/embedding_providers/base.py +1 -1
  49. package/latticeai/core/embedding_providers/text.py +1 -1
  50. package/latticeai/core/enterprise.py +1 -4
  51. package/latticeai/core/http_origin.py +146 -0
  52. package/latticeai/core/invitations.py +3 -2
  53. package/latticeai/core/io_utils.py +2 -11
  54. package/latticeai/core/legacy_compatibility.py +1 -1
  55. package/latticeai/core/marketplace.py +1 -1
  56. package/latticeai/core/messages.py +40 -0
  57. package/latticeai/core/model_compat.py +2 -8
  58. package/latticeai/core/module_probe.py +37 -0
  59. package/latticeai/core/run_explain.py +5 -2
  60. package/latticeai/core/run_store.py +2 -2
  61. package/latticeai/core/security.py +13 -0
  62. package/latticeai/core/sessions.py +3 -2
  63. package/latticeai/core/sse.py +25 -0
  64. package/latticeai/core/users.py +0 -9
  65. package/latticeai/core/workspace_graph_trace.py +2 -8
  66. package/latticeai/core/workspace_os.py +0 -10
  67. package/latticeai/core/workspace_os_constants.py +1 -1
  68. package/latticeai/core/workspace_os_utils.py +34 -0
  69. package/latticeai/core/workspace_review_items.py +2 -8
  70. package/latticeai/core/workspace_runs.py +2 -8
  71. package/latticeai/core/workspace_skills.py +2 -7
  72. package/latticeai/models/router/documents.py +0 -35
  73. package/latticeai/models/router/registry.py +0 -4
  74. package/latticeai/runtime/access_runtime.py +16 -4
  75. package/latticeai/runtime/build_phases/features.py +25 -2
  76. package/latticeai/runtime/build_phases/web.py +2 -0
  77. package/latticeai/runtime/feature_toggle_wiring.py +12 -18
  78. package/latticeai/runtime/network_boundary_wiring.py +8 -19
  79. package/latticeai/runtime/permission_mode_wiring.py +6 -13
  80. package/latticeai/runtime/router_registration.py +4 -0
  81. package/latticeai/runtime/runtime_context.py +1 -13
  82. package/latticeai/runtime/service_singletons.py +55 -0
  83. package/latticeai/services/architecture_readiness.py +1 -1
  84. package/latticeai/services/brain_intelligence/proposals.py +0 -5
  85. package/latticeai/services/change_proposals.py +2 -36
  86. package/latticeai/services/chronicle.py +4 -6
  87. package/latticeai/services/command_center.py +4 -4
  88. package/latticeai/services/evidence_actions.py +5 -2
  89. package/latticeai/services/hybrid_chat.py +2 -2
  90. package/latticeai/services/hybrid_policy.py +8 -57
  91. package/latticeai/services/mode_store.py +132 -0
  92. package/latticeai/services/model_capability_registry.py +0 -15
  93. package/latticeai/services/model_catalog.py +0 -7
  94. package/latticeai/services/model_loading.py +3 -2
  95. package/latticeai/services/model_runtime/__init__.py +0 -57
  96. package/latticeai/services/model_runtime/engines.py +27 -128
  97. package/latticeai/services/model_runtime/loading.py +2 -2
  98. package/latticeai/services/network_boundary_service.py +7 -44
  99. package/latticeai/services/permission_mode_service.py +8 -56
  100. package/latticeai/services/product_readiness.py +1 -1
  101. package/latticeai/services/setup_detection.py +67 -1
  102. package/latticeai/services/tool_dispatch.py +0 -4
  103. package/latticeai/services/upload_service.py +7 -20
  104. package/latticeai/services/workspace_service.py +0 -6
  105. package/latticeai/setup/auto_setup.py +15 -26
  106. package/latticeai/setup/wizard/catalog.py +5 -2
  107. package/latticeai/setup/wizard/detect.py +6 -27
  108. package/latticeai/setup/wizard/paths.py +2 -5
  109. package/latticeai/setup/wizard/plans.py +2 -2
  110. package/package.json +2 -4
  111. package/scripts/bump_version.py +14 -1
  112. package/scripts/check_current_release_docs.mjs +1 -1
  113. package/scripts/check_legacy_debt.mjs +1 -1
  114. package/scripts/check_server_i18n.mjs +1 -0
  115. package/scripts/generate_agent_loop_fixtures.py +994 -0
  116. package/scripts/generate_rust_parity_fixtures.py +72 -161
  117. package/scripts/parity_fixture_corpus_context.py +162 -0
  118. package/scripts/parity_fixture_corpus_docgen.py +341 -0
  119. package/scripts/release_screen_claims.json +20 -0
  120. package/src-tauri/Cargo.lock +13 -7
  121. package/src-tauri/Cargo.toml +1 -1
  122. package/src-tauri/src/backend.rs +76 -2
  123. package/src-tauri/src/main.rs +13 -3
  124. package/src-tauri/tauri.conf.json +2 -2
  125. package/static/app/asset-manifest.json +41 -41
  126. package/static/app/assets/{Act-CWnxSCgN.js → Act-DcQizkl1.js} +1 -1
  127. package/static/app/assets/{AdminConsole-BEQYU6kF.js → AdminConsole-cf4npybT.js} +1 -1
  128. package/static/app/assets/{Brain-DWu1BhFg.js → Brain-3VCSHFcn.js} +1 -1
  129. package/static/app/assets/{BrainHome-95Hilr9R.js → BrainHome-Qm8eaztx.js} +1 -1
  130. package/static/app/assets/{BrainSignals-QdeqCpAF.js → BrainSignals-DS9BtKOW.js} +1 -1
  131. package/static/app/assets/{Capture-BHpCxnzb.js → Capture-DiQ219jW.js} +1 -1
  132. package/static/app/assets/{Chronicle-B4xYKoed.js → Chronicle-BGvuAchH.js} +1 -1
  133. package/static/app/assets/{CommandPalette-BVXnttSz.js → CommandPalette-Bqhm0Urn.js} +1 -1
  134. package/static/app/assets/{Library-DgYcHome.js → Library-BV6NnF0a.js} +1 -1
  135. package/static/app/assets/{LivingBrain-CrJLDbf7.js → LivingBrain-GzenJchP.js} +1 -1
  136. package/static/app/assets/{ProductFlow-DFlScKoJ.js → ProductFlow-DEP6-vML.js} +1 -1
  137. package/static/app/assets/{ReviewCard-Cy5f48Pj.js → ReviewCard-CNZ7XjWG.js} +1 -1
  138. package/static/app/assets/{System-NF8IfhTa.js → System-CieofHQa.js} +1 -1
  139. package/static/app/assets/arrow-left-kfsrk0mv.js +1 -0
  140. package/static/app/assets/{bot-CucuhLhm.js → bot-B_K1Tdmw.js} +1 -1
  141. package/static/app/assets/{brain-BBnSryW_.js → brain-DWyaV1L1.js} +1 -1
  142. package/static/app/assets/{button-C2GUj2Ai.js → button-aTn4s84A.js} +1 -1
  143. package/static/app/assets/circle-check-qqLug9nU.js +1 -0
  144. package/static/app/assets/{circle-pause-CbkWzBmG.js → circle-pause-xKgeGXkT.js} +1 -1
  145. package/static/app/assets/{circle-play-7lEaqHdJ.js → circle-play-DkT6tYPX.js} +1 -1
  146. package/static/app/assets/{cpu-DAlCXlIy.js → cpu-85xYObUC.js} +1 -1
  147. package/static/app/assets/{download-RNhuuJwh.js → download-B5Fm7YXo.js} +1 -1
  148. package/static/app/assets/{folder-open-CLW4odzM.js → folder-open-kk2Xa52u.js} +1 -1
  149. package/static/app/assets/{hard-drive-NKEiDIAJ.js → hard-drive-DkA3zBW_.js} +1 -1
  150. package/static/app/assets/{index-DMurvUuR.js → index-BMPdTmlY.js} +3 -3
  151. package/static/app/assets/index-DxmOfNRi.css +2 -0
  152. package/static/app/assets/{input-D2UhPC1X.js → input-B0nRf2jO.js} +1 -1
  153. package/static/app/assets/{link-2-6amKbP_P.js → link-2-Dwb4gnTc.js} +1 -1
  154. package/static/app/assets/{permissionCopy-Cu9TZtdR.js → permissionCopy-CQDUBrOZ.js} +1 -1
  155. package/static/app/assets/{primitives-gPsccucr.js → primitives-SNp0LRJz.js} +1 -1
  156. package/static/app/assets/search-BcHqkjoy.js +1 -0
  157. package/static/app/assets/{share-2-Bau7KkPq.js → share-2-BsrxFglO.js} +1 -1
  158. package/static/app/assets/{shield-alert-BufNYypi.js → shield-alert-5BStfp2_.js} +1 -1
  159. package/static/app/assets/{textarea-BQnVWhYs.js → textarea-Cg8IUA-k.js} +1 -1
  160. package/static/app/assets/{useFocusTrap-B3_w60si.js → useFocusTrap-CYKvE46M.js} +1 -1
  161. package/static/app/assets/{useMutation-BHhCflT6.js → useMutation-CSn9t1op.js} +1 -1
  162. package/static/app/assets/{useQuery-rBWfI-5t.js → useQuery-CY2OI2uy.js} +1 -1
  163. package/static/app/assets/{utils-V_5-wxr5.js → utils-Ddol2RWD.js} +1 -1
  164. package/static/app/assets/{workspace-K1zjYUHj.js → workspace-BqDwOz_p.js} +1 -1
  165. package/static/app/index.html +4 -4
  166. package/static/sw.js +1 -1
  167. package/desktop/electron/README.md +0 -9
  168. package/desktop/electron/main.cjs +0 -58
  169. package/desktop/electron/preload.cjs +0 -5
  170. package/latticeai/core/graph_curator.py +0 -11
  171. package/latticeai/core/hooks.py +0 -11
  172. package/latticeai/core/local_embeddings.py +0 -104
  173. package/latticeai/core/multi_agent.py +0 -11
  174. package/latticeai/core/workflow_engine.py +0 -11
  175. package/latticeai/services/ingestion.py +0 -11
  176. package/latticeai/services/kg_portability.py +0 -11
  177. package/latticeai/services/multimodal_streaming.py +0 -129
  178. package/scripts/measure_brain_home_fill.mjs +0 -141
  179. package/static/app/assets/arrow-left-DwkSYrjR.js +0 -1
  180. package/static/app/assets/circle-check-CxOVPwYq.js +0 -1
  181. package/static/app/assets/index-BLPb5lmE.css +0 -2
  182. package/static/app/assets/search-Cj_TKk_2.js +0 -1
@@ -0,0 +1,146 @@
1
+ """Which host a request *really* arrived at, when something proxied it.
2
+
3
+ The desktop front door is a reverse proxy: the browser talks to the gateway on
4
+ one port and the gateway forwards to the worker on another. ``Host`` is a
5
+ hop-by-hop header, so it is replaced on the way out and the worker sees only its
6
+ own internal authority — which is why every cookie-authenticated write through
7
+ an adopted worker was rejected as ``csrf_origin_rejected``. The gateway states
8
+ the fact it destroyed in ``X-Forwarded-Host`` / ``X-Forwarded-Proto``; this
9
+ module decides when to believe it.
10
+
11
+ Threat model, so the rule is checkable rather than felt:
12
+
13
+ * **A browser cannot set these headers.** ``X-Forwarded-*`` are not on the
14
+ fetch-safelist and a page cannot add them cross-origin without a preflight
15
+ the CORS allowlist rejects. So no *web page* can reach this code path.
16
+ * **A local non-browser caller could already forge ``Origin``.** curl on this
17
+ machine can send any ``Origin`` it likes, and the loopback exemption in
18
+ :mod:`latticeai.core.csrf` already trusts it. Honouring a forwarded host from
19
+ the same caller widens nothing that was not already open.
20
+ * **Anyone else is not trusted.** Off-loopback the header is honoured only from
21
+ a peer the operator listed in ``LATTICEAI_TRUSTED_PROXIES`` — the same
22
+ allowlist ``latticeai.core.security.client_ip`` uses for the same reason, and
23
+ empty by default.
24
+
25
+ The functions are pure: the caller supplies the headers and the peer address, so
26
+ the policy is testable without an ASGI app or a socket.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import ipaddress
32
+ from typing import Any, Optional
33
+
34
+ __all__ = [
35
+ "FORWARDED_HOST_HEADER",
36
+ "FORWARDED_PROTO_HEADER",
37
+ "effective_host",
38
+ "effective_origin",
39
+ "peer_may_forward",
40
+ "request_external_origin",
41
+ ]
42
+
43
+ #: The authority the client asked for, as stated by the proxy in front of us.
44
+ FORWARDED_HOST_HEADER = "x-forwarded-host"
45
+ #: The scheme the client used to reach that proxy.
46
+ FORWARDED_PROTO_HEADER = "x-forwarded-proto"
47
+
48
+ # Only these two are ever taken from a forwarded scheme; anything else is a
49
+ # value we would have to reason about, and the fallback is already correct.
50
+ _KNOWN_SCHEMES = ("https", "http")
51
+
52
+
53
+ def peer_may_forward(peer: Optional[str]) -> bool:
54
+ """Whether ``X-Forwarded-*`` from this direct peer may be believed.
55
+
56
+ Loopback, or a member of the configured trusted-proxy allowlist. An
57
+ unparseable or absent peer is *not* trusted: a request whose origin we
58
+ cannot establish is exactly the one whose claims we must not take.
59
+ """
60
+ if not peer:
61
+ return False
62
+ try:
63
+ address = ipaddress.ip_address(str(peer).strip())
64
+ except ValueError:
65
+ return False
66
+ if address.is_loopback:
67
+ return True
68
+ from latticeai.core.security import _peer_is_trusted_proxy
69
+
70
+ return _peer_is_trusted_proxy(str(peer).strip())
71
+
72
+
73
+ def _first(value: Optional[str]) -> str:
74
+ """The first entry of a possibly comma-joined forwarded header."""
75
+ return (value or "").split(",")[0].strip()
76
+
77
+
78
+ def effective_host(
79
+ *,
80
+ host: Optional[str],
81
+ forwarded_host: Optional[str] = None,
82
+ peer: Optional[str] = None,
83
+ ) -> Optional[str]:
84
+ """The authority the client aimed at: the forwarded one when believable.
85
+
86
+ Falls back to the request's own ``Host`` in every other case — no proxy, a
87
+ peer we do not trust, or a forwarded header that is empty. ``None`` only
88
+ when there is nothing to report at all (an HTTP/1.0 request with no ``Host``
89
+ and no proxy), which callers already have to handle.
90
+ """
91
+ if peer_may_forward(peer):
92
+ claimed = _first(forwarded_host)
93
+ if claimed:
94
+ return claimed
95
+ own = (host or "").strip()
96
+ return own or None
97
+
98
+
99
+ def effective_origin(
100
+ *,
101
+ host: Optional[str],
102
+ scheme: str = "http",
103
+ forwarded_host: Optional[str] = None,
104
+ forwarded_proto: Optional[str] = None,
105
+ peer: Optional[str] = None,
106
+ ) -> Optional[str]:
107
+ """``scheme://authority`` for the front door this request came through.
108
+
109
+ ``scheme`` is what *this* server is speaking; the forwarded scheme wins when
110
+ the peer is trusted and states one we recognise, because a TLS-terminating
111
+ proxy speaks plain HTTP to us while the browser is on ``https`` and a link
112
+ built with the wrong scheme is a broken link.
113
+ """
114
+ authority = effective_host(host=host, forwarded_host=forwarded_host, peer=peer)
115
+ if not authority:
116
+ return None
117
+ resolved = (scheme or "http").strip().lower() or "http"
118
+ if peer_may_forward(peer):
119
+ claimed = _first(forwarded_proto).lower()
120
+ if claimed in _KNOWN_SCHEMES:
121
+ resolved = claimed
122
+ return f"{resolved}://{authority}"
123
+
124
+
125
+ def request_external_origin(request: Any, *, fallback: Optional[str] = None) -> Optional[str]:
126
+ """:func:`effective_origin` for a live request — the *link-building* seam.
127
+
128
+ CSRF asks "is this same-origin?"; this asks the other question: "what URL
129
+ should I hand a person so they can come back here?" Both must read the same
130
+ front door, or an invite link mails out the internal worker port that only
131
+ the gateway can reach.
132
+
133
+ ``fallback`` is returned when the request states no authority at all, so a
134
+ caller with a configured public URL keeps it.
135
+ """
136
+ headers = getattr(request, "headers", None) or {}
137
+ client = getattr(request, "client", None)
138
+ url = getattr(request, "url", None)
139
+ origin = effective_origin(
140
+ host=headers.get("host"),
141
+ scheme=getattr(url, "scheme", "http") or "http",
142
+ forwarded_host=headers.get(FORWARDED_HOST_HEADER),
143
+ forwarded_proto=headers.get(FORWARDED_PROTO_HEADER),
144
+ peer=getattr(client, "host", None) if client else None,
145
+ )
146
+ return origin or fallback
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import hashlib
6
5
  import json
7
6
  import secrets
8
7
  from datetime import datetime, timedelta
@@ -10,6 +9,7 @@ from pathlib import Path
10
9
  from typing import Any, Dict, List, Optional
11
10
 
12
11
  from latticeai.core.quiet import quiet
12
+ from latticeai.core.security import sha256_hex
13
13
 
14
14
  from .timeutil import local_now as _now
15
15
 
@@ -19,7 +19,8 @@ def _iso(dt: datetime) -> str:
19
19
 
20
20
 
21
21
  def _hash_token(token: str) -> str:
22
- return hashlib.sha256(token.encode("utf-8")).hexdigest()
22
+ """Strict by design: an invite code is always a string by the time it lands here."""
23
+ return sha256_hex(token)
23
24
 
24
25
 
25
26
  def _atomic_write(path: Path, data: Dict[str, Any]) -> None:
@@ -5,10 +5,10 @@ from __future__ import annotations
5
5
  import hashlib
6
6
  import json
7
7
  import os
8
- from datetime import datetime
9
8
  from pathlib import Path
10
- from typing import Any, Dict, Optional
9
+ from typing import Any, Dict
11
10
 
11
+ from lattice_brain.utils import parse_iso as parse_iso
12
12
  from latticeai.core.quiet import quiet
13
13
 
14
14
 
@@ -27,15 +27,6 @@ def atomic_write_json(path: Path, payload: Dict[str, Any]) -> None:
27
27
  quiet()
28
28
 
29
29
 
30
- def parse_iso(value: Optional[str]) -> Optional[datetime]:
31
- if not value:
32
- return None
33
- try:
34
- return datetime.fromisoformat(str(value))
35
- except (TypeError, ValueError):
36
- return None
37
-
38
-
39
30
  def sha256_file(path: Path) -> str:
40
31
  digest = hashlib.sha256()
41
32
  with open(path, "rb") as fh:
@@ -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.5.0"
16
+ LEGACY_COMPATIBILITY_VERSION = "11.5.2"
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.5.0"
13
+ MARKETPLACE_VERSION = "11.5.2"
14
14
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
15
15
 
16
16
 
@@ -536,6 +536,41 @@ MESSAGES: Dict[str, Dict[str, str]] = {
536
536
  "ko": "한 번에 처리할 개수는 {min}에서 {max} 사이여야 합니다.",
537
537
  "en": "Ask for between {min} and {max} items in one pass.",
538
538
  },
539
+ # ── AI-Worker seam (the Rust loop's three calls back into Python) ────
540
+ "agent_seam.disabled": {
541
+ "ko": "워커 시임이 꺼져 있습니다. 호스트가 직접 띄운 워커에서만 열립니다.",
542
+ "en": "The worker seam is off. It opens only in a worker the host started itself.",
543
+ },
544
+ "agent_seam.message_required": {
545
+ "ko": "모델에 보낼 내용을 적어주세요.",
546
+ "en": "Write the message to send to the model.",
547
+ },
548
+ "agent_seam.max_tokens_out_of_range": {
549
+ "ko": "한 번에 만들 길이는 {min}에서 {max} 사이여야 합니다.",
550
+ "en": "Ask for between {min} and {max} tokens in one completion.",
551
+ },
552
+ "agent_seam.temperature_out_of_range": {
553
+ "ko": "온도는 {min}에서 {max} 사이여야 합니다.",
554
+ "en": "Temperature has to be between {min} and {max}.",
555
+ },
556
+ "agent_seam.tool_required": {
557
+ "ko": "실행할 도구 이름이 필요합니다.",
558
+ "en": "A tool name to run is required.",
559
+ },
560
+ "agent_seam.tool_blocked": {
561
+ "ko": "'{tool}' 도구는 어떤 모드에서도 차단됩니다 ({reason}).",
562
+ "en": "'{tool}' is blocked in every mode ({reason}).",
563
+ },
564
+ "agent_seam.tool_fail_closed": {
565
+ "ko": "'{tool}' 도구는 기존 내용을 바꾸지만 검토할 수 있는 제안으로 "
566
+ "만들 수 없어 차단했습니다 ({reason}).",
567
+ "en": "'{tool}' would change existing content but cannot be staged as a "
568
+ "reviewable proposal, so it is blocked ({reason}).",
569
+ },
570
+ "agent_seam.proposals_unavailable": {
571
+ "ko": "변경 제안 서비스가 연결되어 있지 않습니다.",
572
+ "en": "The change proposal service is not connected.",
573
+ },
539
574
  # ── MCP / setup ─────────────────────────────────────────────────────
540
575
  "mcp.connector_not_found": {
541
576
  "ko": "커넥터를 찾을 수 없습니다.",
@@ -588,6 +623,11 @@ MESSAGES: Dict[str, Dict[str, str]] = {
588
623
  }
589
624
 
590
625
 
626
+ def bilingual(ko: str, en: str) -> Dict[str, str]:
627
+ """One Korean/English phrase pair, in the shape every caller renders."""
628
+ return {"ko": ko, "en": en}
629
+
630
+
591
631
  def resolve_language(request: Any, default: str = DEFAULT_LANGUAGE) -> str:
592
632
  """Language for this request: the product's choice, then the browser's.
593
633
 
@@ -12,7 +12,6 @@
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- import importlib.util
16
15
  import json
17
16
  import logging
18
17
  import re
@@ -22,6 +21,8 @@ from dataclasses import asdict, dataclass
22
21
  from pathlib import Path
23
22
  from typing import Any, Dict, List, Optional, Tuple
24
23
 
24
+ from latticeai.core.module_probe import module_available as _module_available
25
+
25
26
  logger = logging.getLogger(__name__)
26
27
 
27
28
 
@@ -198,13 +199,6 @@ GEMMA4_MLX_LM_MODULES = ("mlx_lm.models.gemma4", "mlx_lm.models.gemma4_text")
198
199
  GEMMA4_UNIFIED_ID_PATTERN = re.compile(r"gemma[-_/ ]?4[-_/ ]?12b", re.I)
199
200
 
200
201
 
201
- def _module_available(module: str) -> bool:
202
- try:
203
- return importlib.util.find_spec(module) is not None
204
- except (ImportError, ModuleNotFoundError, AttributeError, ValueError):
205
- return False
206
-
207
-
208
202
  def _is_gemma4(model_id: str) -> bool:
209
203
  raw = str(model_id or "").lower()
210
204
  return bool(re.search(r"gemma[-_/ ]?4", raw))
@@ -0,0 +1,37 @@
1
+ """One answer to "is this module importable?".
2
+
3
+ Four modules asked it four different ways: two consulted ``find_spec``, one
4
+ imported for real, and they disagreed about which failures mean "no" and which
5
+ should escape. The disagreement mattered — the setup probe's copy imports for
6
+ real *on purpose*, because a native wheel like ``mlx`` can have a perfectly
7
+ findable spec on the wrong architecture and still raise when it loads. A copy
8
+ that only looked for the spec would report the runtime as present and let the
9
+ recommendation engine plan an install around a module that cannot run.
10
+
11
+ So the two behaviours stay, named: ``strict=False`` asks whether Python can
12
+ *find* the module (cheap, no side effects, no import cost); ``strict=True``
13
+ actually imports it and answers whether it *works*.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib
19
+ import importlib.util
20
+
21
+ __all__ = ["module_available"]
22
+
23
+
24
+ def module_available(name: str, *, strict: bool = False) -> bool:
25
+ """Report whether ``name`` can be imported, never raising.
26
+
27
+ A probe that raises is a probe that takes the caller down for asking, and
28
+ every caller here is deciding what to *offer* the user — "I could not tell"
29
+ and "it is not there" lead to the same honest answer.
30
+ """
31
+ try:
32
+ if strict:
33
+ importlib.import_module(name)
34
+ return True
35
+ return importlib.util.find_spec(name) is not None
36
+ except Exception:
37
+ return False
@@ -30,6 +30,8 @@ from __future__ import annotations
30
30
 
31
31
  from typing import Any, Dict, List, Mapping, Optional, Sequence
32
32
 
33
+ from latticeai.core.messages import bilingual as _bilingual
34
+
33
35
  __all__ = ["explain_run", "STRAIN_LEVELS"]
34
36
 
35
37
 
@@ -53,8 +55,9 @@ _REPAIR_GROUPS: Dict[str, tuple] = {
53
55
  }
54
56
 
55
57
 
56
- def _phrase(ko: str, en: str) -> Dict[str, str]:
57
- return {"ko": ko, "en": en}
58
+
59
+ #: Historical module-local name for the shared phrase-pair helper.
60
+ _phrase = _bilingual
58
61
 
59
62
 
60
63
  def _int(value: Any) -> int:
@@ -21,7 +21,6 @@ Design constraints (deliberate, matching :mod:`latticeai.services.funnel_metrics
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
- import hashlib
25
24
  import json
26
25
  import logging
27
26
  import os
@@ -34,6 +33,7 @@ from typing import Any, Dict, List, Optional
34
33
  from latticeai.core.agent import AgentRunContext, AgentState
35
34
  from latticeai.core.agent_trace import LoopTrace
36
35
  from latticeai.core.quiet import quiet
36
+ from latticeai.core.security import sha256_hex
37
37
 
38
38
  LOGGER = logging.getLogger(__name__)
39
39
 
@@ -44,7 +44,7 @@ _RUN_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
44
44
 
45
45
  def hash_approval_token(token: str) -> str:
46
46
  """Stable digest used both at save and at resume comparison time."""
47
- return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()
47
+ return sha256_hex(str(token or ""))
48
48
 
49
49
 
50
50
  def serialize_run_context(ctx: AgentRunContext) -> Dict[str, Any]:
@@ -13,6 +13,19 @@ from fastapi import HTTPException
13
13
  from latticeai.core.quiet import quiet
14
14
 
15
15
 
16
+ def sha256_hex(text: str) -> str:
17
+ """The one UTF-8 SHA-256 hex digest.
18
+
19
+ Six modules each carried this three-token expression, and they did not
20
+ agree on what to do with ``None``: two coerced it away, one raised. The
21
+ helper stays **strict** on purpose — coercing here would silently give
22
+ every caller the digest of the empty string, which is a valid-looking
23
+ token hash for "no token at all". Each call site keeps the contract it
24
+ already had, stated in its own body where a reader can see it.
25
+ """
26
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
27
+
28
+
16
29
  def hash_password(password: str) -> str:
17
30
  salt = secrets.token_hex(16)
18
31
  key = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1)
@@ -6,7 +6,6 @@ holding raw tokens are migrated transparently on load (sessions survive the
6
6
  upgrade; the raw token never touches disk again).
7
7
  """
8
8
 
9
- import hashlib
10
9
  import json
11
10
  import logging
12
11
  import secrets
@@ -16,6 +15,7 @@ from pathlib import Path
16
15
  from typing import Dict, Optional
17
16
 
18
17
  from latticeai.core.io_utils import atomic_write_json
18
+ from latticeai.core.security import sha256_hex
19
19
 
20
20
  SESSION_TTL = 60 * 60 * 24 # 24 hours
21
21
  SESSION_REFRESH_THRESHOLD = 60 * 15 # only persist if >15 min since last bump
@@ -25,7 +25,8 @@ _HEX64 = frozenset("0123456789abcdef")
25
25
 
26
26
 
27
27
  def _hash_token(token: str) -> str:
28
- return hashlib.sha256((token or "").encode("utf-8")).hexdigest()
28
+ """Session keys are hashed at rest; a missing token hashes the empty string."""
29
+ return sha256_hex(token or "")
29
30
 
30
31
 
31
32
  def _looks_hashed(key: str) -> bool:
@@ -0,0 +1,25 @@
1
+ """The one Server-Sent Events frame builder.
2
+
3
+ Five modules each formatted their own frame — three in the
4
+ ``event:``/``data:`` shape and two in the bare ``data:`` shape — with the same
5
+ ``json.dumps(..., ensure_ascii=False)`` payload. They agreed by accident, and a
6
+ frame that disagrees by one byte is a stream the browser silently stops
7
+ parsing, so the agreement is now structural rather than coincidental.
8
+
9
+ ``event=None`` produces the bare shape; a named event produces the prefixed
10
+ one. Both are byte-identical to the copies they replaced.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import Any, Optional
17
+
18
+ __all__ = ["sse_frame"]
19
+
20
+
21
+ def sse_frame(event: Optional[str], data: Any) -> str:
22
+ """Render one SSE frame, terminated by the blank line that ends it."""
23
+ payload = json.dumps(data, ensure_ascii=False)
24
+ prefix = f"event: {event}\n" if event else ""
25
+ return f"{prefix}data: {payload}\n\n"
@@ -100,15 +100,6 @@ def user_id_for_email(users: Dict[str, Any], email: Optional[str]) -> Optional[s
100
100
  return stable_user_id(normalized)
101
101
 
102
102
 
103
- def email_for_user_id(users: Dict[str, Any], user_id: Optional[str]) -> Optional[str]:
104
- if not user_id:
105
- return None
106
- for email, user in (users or {}).items():
107
- if isinstance(user, dict) and user.get("id") == user_id:
108
- return email
109
- return None
110
-
111
-
112
103
  def migrate_knowledge_graph_identity(db_path: Path, email_to_id: Dict[str, str]) -> int:
113
104
  """Rewrite KG owner/creator identity columns from email to stable UUIDs."""
114
105
  if not db_path.exists() or not email_to_id:
@@ -6,16 +6,10 @@ from typing import Any, Dict, List, Optional
6
6
  from latticeai.core.quiet import quiet
7
7
 
8
8
  from .timeutil import now_iso as _now
9
- from .workspace_os_utils import _json_hash, _listify
9
+ from .workspace_os_utils import WorkspaceStoreMixin, _json_hash, _listify
10
10
 
11
11
 
12
- class WorkspaceGraphTrace:
13
- def __init__(self, store: Any):
14
- self._store = store
15
-
16
- def __getattr__(self, name: str) -> Any:
17
- return getattr(self._store, name)
18
-
12
+ class WorkspaceGraphTrace(WorkspaceStoreMixin):
19
13
  def build_graph_trace(
20
14
  self,
21
15
  question: str,
@@ -53,7 +53,6 @@ from .workspace_permissions import ( # type: ignore
53
53
  from .workspace_plugins import WorkspacePluginManager
54
54
  from .workspace_relationships import WorkspaceRelationships
55
55
  from .workspace_relationships import shortest_path as _shortest_path
56
- from .workspace_reorganization import plan_reorganization
57
56
  from .workspace_review_items import WorkspaceReviewItems
58
57
  from .workspace_runs import WorkspaceRuns
59
58
  from .workspace_skills import WorkspaceSkills
@@ -844,15 +843,6 @@ class WorkspaceOSStore:
844
843
  # Agent-native workspace: folder reorganization (v11.1.0)
845
844
  # ------------------------------------------------------------------
846
845
 
847
- def preview_reorganization(
848
- self, *, root: str = "", resolve_path: Callable[[str], Any], graph: Any = None,
849
- max_moves: int = 20,
850
- ) -> Dict[str, Any]:
851
- """What a reorganization would do — read-only, nothing is staged."""
852
- return plan_reorganization(
853
- root=root, resolve_path=resolve_path, graph=graph, max_moves=max_moves
854
- )
855
-
856
846
  def propose_reorganization(
857
847
  self, *, root: str = "", change_proposals: Any, graph: Any = None,
858
848
  user_email: Optional[str] = None, workspace_id: Optional[str] = None,
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  from typing import Dict
12
12
 
13
- WORKSPACE_OS_VERSION = "11.5.0"
13
+ WORKSPACE_OS_VERSION = "11.5.2"
14
14
 
15
15
  # Workspace types separate single-user Personal workspaces from shared
16
16
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -17,6 +17,40 @@ from .io_utils import (
17
17
  from .io_utils import parse_iso as _parse_iso # noqa: F401 - legacy helper re-export
18
18
 
19
19
 
20
+ class WorkspaceStoreMixin:
21
+ """A collaborator that reads and writes through ``WorkspaceOSStore``.
22
+
23
+ The store was decomposed into managers that each own one branch of the state
24
+ document, and every one of them needs the same two things: a reference to the
25
+ store, and for anything it does not implement itself (``load_state``,
26
+ ``_scoped``, ``record_timeline_event``) to reach the store instead of raising
27
+ ``AttributeError``. Four managers wrote that out identically; the pair lives
28
+ here so a fifth cannot invent a third spelling of it.
29
+ """
30
+
31
+ def __init__(self, store: Any) -> None:
32
+ self._store = store
33
+
34
+ def __getattr__(self, name: str) -> Any:
35
+ # Only reached for attributes this manager does not define, so the
36
+ # store is the answer for every state-document operation.
37
+ return getattr(self._store, name)
38
+
39
+
40
+ def graph_scope_kwargs(workspace_id: Any) -> Dict[str, Any]:
41
+ """The scoped-read kwargs the graph store expects (house pattern).
42
+
43
+ ``workspace_id is None`` means "unscoped caller": it reads everything it is
44
+ allowed to, legacy rows that predate workspaces included. A named workspace
45
+ reads only that workspace and never the legacy pool — which is the whole
46
+ isolation guarantee, so it is stated once.
47
+ """
48
+ return {
49
+ "allowed_workspaces": {workspace_id} if workspace_id is not None else None,
50
+ "include_legacy_global": workspace_id is None,
51
+ }
52
+
53
+
20
54
  def _safe_slug(raw: str) -> str:
21
55
  value = "".join(ch if ch.isalnum() or ch in "-_." else "-" for ch in str(raw or "").strip())
22
56
  value = "-".join(part for part in value.split("-") if part)
@@ -14,16 +14,10 @@ from __future__ import annotations
14
14
  from typing import Any, Dict, List, Optional
15
15
 
16
16
  from .timeutil import now_iso as _now
17
- from .workspace_os_utils import _json_hash, _listify
17
+ from .workspace_os_utils import WorkspaceStoreMixin, _json_hash, _listify
18
18
 
19
19
 
20
- class WorkspaceReviewItems:
21
- def __init__(self, store: Any):
22
- self._store = store
23
-
24
- def __getattr__(self, name: str) -> Any:
25
- return getattr(self._store, name)
26
-
20
+ class WorkspaceReviewItems(WorkspaceStoreMixin):
27
21
  def create_review_item(
28
22
  self,
29
23
  *,
@@ -8,16 +8,10 @@ from lattice_brain.runtime.contracts import run_record_contract, workflow_run_co
8
8
  from lattice_brain.runtime.statuses import RUN_ACTIVE_STATUSES, RUN_TERMINAL_STATUSES
9
9
 
10
10
  from .timeutil import now_iso as _now
11
- from .workspace_os_utils import _json_hash, _listify
11
+ from .workspace_os_utils import WorkspaceStoreMixin, _json_hash, _listify
12
12
 
13
13
 
14
- class WorkspaceRuns:
15
- def __init__(self, store: Any):
16
- self._store = store
17
-
18
- def __getattr__(self, name: str) -> Any:
19
- return getattr(self._store, name)
20
-
14
+ class WorkspaceRuns(WorkspaceStoreMixin):
21
15
  def list_agents(self, workspace_id: Optional[str] = None) -> Dict[str, Any]:
22
16
  state = self.load_state()
23
17
  runs = self._scoped(_listify(state.get("agent_runs")), workspace_id)
@@ -6,15 +6,10 @@ from pathlib import Path
6
6
  from typing import Any, Dict, List, Optional
7
7
 
8
8
  from .timeutil import now_iso as _now
9
+ from .workspace_os_utils import WorkspaceStoreMixin
9
10
 
10
11
 
11
- class WorkspaceSkills:
12
- def __init__(self, store: Any):
13
- self._store = store
14
-
15
- def __getattr__(self, name: str) -> Any:
16
- return getattr(self._store, name)
17
-
12
+ class WorkspaceSkills(WorkspaceStoreMixin):
18
13
  def list_skill_registry(self, skills_dir: Path, marketplace: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
19
14
  state = self.load_state()
20
15
  registry = state.setdefault("skill_registry", {})