ltcai 8.9.0 → 9.1.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 (208) hide show
  1. package/README.md +81 -58
  2. package/auto_setup.py +7 -904
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +221 -238
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  13. package/docs/TRUST_MODEL.md +5 -2
  14. package/docs/WHY_LATTICE.md +15 -10
  15. package/docs/WORKFLOW_DESIGNER.md +22 -0
  16. package/docs/kg-schema.md +13 -2
  17. package/docs/mcp-tools.md +17 -6
  18. package/docs/privacy.md +19 -3
  19. package/docs/public-deploy.md +32 -3
  20. package/docs/security-model.md +46 -11
  21. package/lattice_brain/__init__.py +1 -1
  22. package/lattice_brain/archive.py +4 -14
  23. package/lattice_brain/context.py +66 -9
  24. package/lattice_brain/embeddings.py +38 -2
  25. package/lattice_brain/graph/_kg_common.py +27 -462
  26. package/lattice_brain/graph/_kg_constants.py +243 -0
  27. package/lattice_brain/graph/_kg_fsutil.py +293 -0
  28. package/lattice_brain/graph/discovery.py +0 -948
  29. package/lattice_brain/graph/discovery_index.py +1126 -0
  30. package/lattice_brain/graph/documents.py +44 -9
  31. package/lattice_brain/graph/ingest.py +47 -20
  32. package/lattice_brain/graph/provenance.py +13 -6
  33. package/lattice_brain/graph/retrieval.py +141 -610
  34. package/lattice_brain/graph/retrieval_docgen.py +243 -0
  35. package/lattice_brain/graph/retrieval_vector.py +460 -0
  36. package/lattice_brain/graph/store.py +6 -0
  37. package/lattice_brain/ingestion.py +69 -4
  38. package/lattice_brain/portability.py +29 -23
  39. package/lattice_brain/quality.py +98 -4
  40. package/lattice_brain/runtime/agent_runtime.py +169 -7
  41. package/lattice_brain/runtime/hooks.py +30 -13
  42. package/lattice_brain/runtime/multi_agent.py +27 -8
  43. package/lattice_brain/runtime/statuses.py +10 -0
  44. package/lattice_brain/utils.py +46 -0
  45. package/lattice_brain/workflow.py +1 -5
  46. package/latticeai/__init__.py +1 -1
  47. package/latticeai/api/admin.py +1 -1
  48. package/latticeai/api/agent_registry.py +10 -8
  49. package/latticeai/api/agents.py +22 -3
  50. package/latticeai/api/auth.py +78 -16
  51. package/latticeai/api/browser.py +303 -34
  52. package/latticeai/api/chat.py +355 -952
  53. package/latticeai/api/chat_agent_http.py +356 -0
  54. package/latticeai/api/chat_contracts.py +54 -0
  55. package/latticeai/api/chat_documents.py +256 -0
  56. package/latticeai/api/chat_helpers.py +250 -0
  57. package/latticeai/api/chat_history.py +99 -0
  58. package/latticeai/api/chat_intents.py +302 -0
  59. package/latticeai/api/chat_stream.py +115 -0
  60. package/latticeai/api/computer_use.py +211 -40
  61. package/latticeai/api/health.py +9 -3
  62. package/latticeai/api/hooks.py +17 -6
  63. package/latticeai/api/knowledge_graph.py +78 -14
  64. package/latticeai/api/local_files.py +7 -2
  65. package/latticeai/api/marketplace.py +11 -0
  66. package/latticeai/api/mcp.py +102 -23
  67. package/latticeai/api/models.py +72 -33
  68. package/latticeai/api/network.py +5 -5
  69. package/latticeai/api/permissions.py +70 -29
  70. package/latticeai/api/plugins.py +21 -7
  71. package/latticeai/api/portability.py +5 -5
  72. package/latticeai/api/realtime.py +33 -5
  73. package/latticeai/api/setup.py +2 -2
  74. package/latticeai/api/static_routes.py +123 -24
  75. package/latticeai/api/tools.py +97 -14
  76. package/latticeai/api/workflow_designer.py +37 -0
  77. package/latticeai/api/workspace.py +2 -1
  78. package/latticeai/app_factory.py +185 -405
  79. package/latticeai/core/agent.py +19 -9
  80. package/latticeai/core/agent_registry.py +1 -5
  81. package/latticeai/core/config.py +23 -3
  82. package/latticeai/core/context_builder.py +21 -3
  83. package/latticeai/core/invitations.py +1 -4
  84. package/latticeai/core/io_utils.py +45 -0
  85. package/latticeai/core/legacy_compatibility.py +24 -3
  86. package/latticeai/core/local_embeddings.py +2 -4
  87. package/latticeai/core/marketplace.py +33 -2
  88. package/latticeai/core/mcp_catalog.py +450 -0
  89. package/latticeai/core/mcp_registry.py +2 -441
  90. package/latticeai/core/plugins.py +15 -0
  91. package/latticeai/core/policy.py +1 -1
  92. package/latticeai/core/realtime.py +38 -15
  93. package/latticeai/core/sessions.py +7 -2
  94. package/latticeai/core/timeutil.py +10 -0
  95. package/latticeai/core/tool_registry.py +90 -18
  96. package/latticeai/core/users.py +5 -14
  97. package/latticeai/core/workspace_graph_trace.py +31 -5
  98. package/latticeai/core/workspace_memory.py +3 -1
  99. package/latticeai/core/workspace_os.py +18 -7
  100. package/latticeai/core/workspace_os_utils.py +2 -21
  101. package/latticeai/core/workspace_permissions.py +2 -1
  102. package/latticeai/core/workspace_plugins.py +1 -1
  103. package/latticeai/core/workspace_runs.py +14 -5
  104. package/latticeai/core/workspace_skills.py +1 -1
  105. package/latticeai/core/workspace_snapshots.py +2 -1
  106. package/latticeai/core/workspace_timeline.py +2 -1
  107. package/latticeai/integrations/telegram_bot.py +96 -40
  108. package/latticeai/models/model_providers.py +111 -0
  109. package/latticeai/models/router.py +189 -173
  110. package/latticeai/runtime/access_runtime.py +62 -4
  111. package/latticeai/runtime/audit_runtime.py +27 -16
  112. package/latticeai/runtime/automation_runtime.py +22 -7
  113. package/latticeai/runtime/brain_runtime.py +19 -7
  114. package/latticeai/runtime/chat_wiring.py +2 -0
  115. package/latticeai/runtime/config_runtime.py +58 -26
  116. package/latticeai/runtime/context_runtime.py +16 -4
  117. package/latticeai/runtime/history_runtime.py +163 -0
  118. package/latticeai/runtime/hooks_runtime.py +1 -1
  119. package/latticeai/runtime/model_wiring.py +33 -5
  120. package/latticeai/runtime/namespace_runtime.py +163 -0
  121. package/latticeai/runtime/network_config_runtime.py +56 -0
  122. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  123. package/latticeai/runtime/review_wiring.py +1 -1
  124. package/latticeai/runtime/router_registration.py +34 -1
  125. package/latticeai/runtime/security_runtime.py +110 -13
  126. package/latticeai/runtime/sso_config_runtime.py +128 -0
  127. package/latticeai/runtime/stages.py +27 -0
  128. package/latticeai/runtime/user_key_runtime.py +106 -0
  129. package/latticeai/server_app.py +5 -1
  130. package/latticeai/services/architecture_readiness.py +74 -5
  131. package/latticeai/services/brain_automation.py +3 -26
  132. package/latticeai/services/chat_service.py +205 -15
  133. package/latticeai/services/local_knowledge.py +423 -0
  134. package/latticeai/services/memory_service.py +268 -21
  135. package/latticeai/services/model_engines.py +48 -33
  136. package/latticeai/services/model_errors.py +17 -0
  137. package/latticeai/services/model_loading.py +28 -25
  138. package/latticeai/services/model_runtime.py +228 -162
  139. package/latticeai/services/p_reinforce.py +22 -3
  140. package/latticeai/services/platform_runtime.py +92 -24
  141. package/latticeai/services/product_readiness.py +19 -17
  142. package/latticeai/services/review_queue.py +76 -11
  143. package/latticeai/services/router_context.py +1 -0
  144. package/latticeai/services/run_executor.py +25 -9
  145. package/latticeai/services/search_service.py +203 -28
  146. package/latticeai/services/setup_detection.py +80 -0
  147. package/latticeai/services/tool_dispatch.py +28 -5
  148. package/latticeai/services/triggers.py +53 -4
  149. package/latticeai/services/upload_service.py +13 -2
  150. package/latticeai/setup/__init__.py +25 -0
  151. package/latticeai/setup/auto_setup.py +857 -0
  152. package/latticeai/setup/wizard.py +1264 -0
  153. package/latticeai/tools/__init__.py +278 -0
  154. package/{tools → latticeai/tools}/commands.py +67 -7
  155. package/{tools → latticeai/tools}/computer.py +1 -1
  156. package/{tools → latticeai/tools}/documents.py +1 -1
  157. package/{tools → latticeai/tools}/filesystem.py +3 -3
  158. package/latticeai/tools/knowledge.py +178 -0
  159. package/{tools → latticeai/tools}/local_files.py +7 -1
  160. package/{tools → latticeai/tools}/network.py +0 -1
  161. package/local_knowledge_api.py +4 -342
  162. package/package.json +22 -2
  163. package/scripts/brain_quality_eval.py +3 -1
  164. package/scripts/bump_version.py +3 -0
  165. package/scripts/capture_release_evidence.mjs +180 -0
  166. package/scripts/check_current_release_docs.mjs +142 -0
  167. package/scripts/check_i18n_literals.mjs +107 -31
  168. package/scripts/check_openapi_drift.mjs +56 -0
  169. package/scripts/export_openapi.py +67 -7
  170. package/scripts/i18n_literal_allowlist.json +22 -24
  171. package/scripts/run_integration_tests.mjs +72 -10
  172. package/scripts/validate_release_artifacts.py +49 -7
  173. package/scripts/wheel_smoke.py +5 -0
  174. package/setup_wizard.py +3 -1304
  175. package/src-tauri/Cargo.lock +1 -1
  176. package/src-tauri/Cargo.toml +1 -1
  177. package/src-tauri/tauri.conf.json +1 -1
  178. package/static/app/asset-manifest.json +11 -11
  179. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  180. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  181. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  182. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  183. package/static/app/assets/System-80lHW0Ux.js +1 -0
  184. package/static/app/assets/index-BiMofBTM.js +17 -0
  185. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  186. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  187. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  188. package/static/app/index.html +2 -2
  189. package/static/css/tokens.css +4 -2
  190. package/static/sw.js +1 -1
  191. package/tools/__init__.py +21 -269
  192. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  193. package/latticeai/runtime/app_context_runtime.py +0 -13
  194. package/latticeai/runtime/sso_runtime.py +0 -52
  195. package/latticeai/runtime/tail_wiring.py +0 -21
  196. package/scripts/com.pts.claudecode.discord.plist +0 -31
  197. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  198. package/scripts/start-pts-claudecode-discord.sh +0 -51
  199. package/static/app/assets/Act-fZokUnC0.js +0 -1
  200. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  201. package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
  202. package/static/app/assets/Library-C9kyFkSt.js +0 -1
  203. package/static/app/assets/System-VbChmX7r.js +0 -1
  204. package/static/app/assets/index-DCh5AoXt.css +0 -2
  205. package/static/app/assets/index-DPdcPoF0.js +0 -17
  206. package/static/app/assets/primitives-DFeanEV6.js +0 -1
  207. package/static/app/assets/textarea-CD8UNKIy.js +0 -1
  208. package/tools/knowledge.py +0 -95
@@ -16,11 +16,11 @@ import shutil
16
16
  import time
17
17
  import urllib.error
18
18
  import urllib.request
19
- import warnings
19
+ from dataclasses import dataclass, field, fields
20
20
  from pathlib import Path
21
- from typing import AsyncIterator, Dict, List, Optional
21
+ from typing import Any, AsyncIterator, Callable, Dict, List, Optional
22
22
 
23
- from fastapi import HTTPException, Request
23
+ from .model_errors import ModelRuntimeError
24
24
 
25
25
  from latticeai.models.router import (
26
26
  AsyncOpenAI,
@@ -66,111 +66,68 @@ _MODEL_LOADING_COMPAT_EXPORTS = (
66
66
  )
67
67
 
68
68
 
69
- def _missing_current_user(_request: Request) -> Optional[str]:
69
+ def _missing_current_user(_request: Any) -> Optional[str]:
70
70
  return None
71
71
 
72
72
 
73
73
  def _missing_user_api_key(_email: Optional[str], _provider: str) -> Optional[str]:
74
74
  return None
75
75
 
76
- # Server decomp: proper ModelRuntimeState class for globals/wiring
76
+
77
+ @dataclass(frozen=True, slots=True)
77
78
  class ModelRuntimeState:
78
- """Central object for all legacy globals. Module level names delegate for compat.
79
- This is the clean wiring surface for future decomp.
79
+ """Immutable application-owned dependencies for one model runtime.
80
+
81
+ Upper-case configuration field names intentionally match the long-standing
82
+ composition-root vocabulary. Unlike the former module ``STATE`` object,
83
+ instances are explicit, immutable, and safe to create more than once in a
84
+ process (for example in isolated tests or multiple ASGI applications).
80
85
  """
81
- def __init__(self):
82
- self.router = None
83
- self.APP_MODE = "local"
84
- self.DEFAULT_HOST = "127.0.0.1"
85
- self.DEFAULT_PORT = 4825
86
- self.DATA_DIR = Path.home() / ".latticeai"
87
- self.BASE_DIR = Path.cwd()
88
- self.ENABLE_TELEGRAM = False
89
- self.ENABLE_GRAPH = True
90
- self.AUTOLOAD_MODELS = False
91
- self.MODEL_IDLE_UNLOAD_SECONDS = 0
92
- self.ALLOW_MODEL_DOWNLOADS = False
93
- self.MODEL_DOWNLOAD_TIMEOUT = 300
94
- self.ALLOW_LOCAL_MODELS = True
95
- self.REQUIRE_AUTH = False
96
- self.INVITE_GATE_ENABLED = False
97
- self.ALLOW_PLAINTEXT_API_KEYS = False
98
- self.CORS_ALLOW_NETWORK = False
99
- self.PUBLIC_MODEL = "openai:gpt-4o-mini"
100
- self.LOCAL_MODEL = "mlx-community/gemma-4-12b-it-4bit"
101
- self.IS_PUBLIC_MODE = False
102
- self.keyring = None
103
- self.get_current_user = _missing_current_user
104
- self.get_user_api_key = _missing_user_api_key
105
-
106
- def sync_to_module_globals(self):
107
- """Sync to bare module globals for legacy external importers.
108
-
109
- This is a compatibility surface only. Internal code should use STATE.
110
- Emits DeprecationWarning (future removal in major after 8.x).
111
- """
112
- warnings.warn(
113
- "sync_to_module_globals is a legacy compatibility shim and will be removed "
114
- "after 8.x. Use latticeai.services.model_runtime.STATE or injected context instead.",
115
- DeprecationWarning,
116
- stacklevel=2,
117
- )
118
- self._sync_globals()
119
-
120
- def _sync_globals(self) -> None:
121
- """Internal no-warning sync used at init."""
122
- global router, APP_MODE, DEFAULT_HOST, DEFAULT_PORT, DATA_DIR, BASE_DIR
123
- global ENABLE_TELEGRAM, ENABLE_GRAPH, AUTOLOAD_MODELS, MODEL_IDLE_UNLOAD_SECONDS
124
- global ALLOW_MODEL_DOWNLOADS, MODEL_DOWNLOAD_TIMEOUT, ALLOW_LOCAL_MODELS, REQUIRE_AUTH
125
- global INVITE_GATE_ENABLED, ALLOW_PLAINTEXT_API_KEYS
126
- global CORS_ALLOW_NETWORK, PUBLIC_MODEL, LOCAL_MODEL, IS_PUBLIC_MODE
127
- global keyring, get_current_user, get_user_api_key
128
- router = self.router
129
- APP_MODE = self.APP_MODE
130
- DEFAULT_HOST = self.DEFAULT_HOST
131
- DEFAULT_PORT = self.DEFAULT_PORT
132
- DATA_DIR = self.DATA_DIR
133
- BASE_DIR = self.BASE_DIR
134
- ENABLE_TELEGRAM = self.ENABLE_TELEGRAM
135
- ENABLE_GRAPH = self.ENABLE_GRAPH
136
- AUTOLOAD_MODELS = self.AUTOLOAD_MODELS
137
- MODEL_IDLE_UNLOAD_SECONDS = self.MODEL_IDLE_UNLOAD_SECONDS
138
- ALLOW_MODEL_DOWNLOADS = self.ALLOW_MODEL_DOWNLOADS
139
- MODEL_DOWNLOAD_TIMEOUT = self.MODEL_DOWNLOAD_TIMEOUT
140
- ALLOW_LOCAL_MODELS = self.ALLOW_LOCAL_MODELS
141
- REQUIRE_AUTH = self.REQUIRE_AUTH
142
- INVITE_GATE_ENABLED = self.INVITE_GATE_ENABLED
143
- ALLOW_PLAINTEXT_API_KEYS = self.ALLOW_PLAINTEXT_API_KEYS
144
- CORS_ALLOW_NETWORK = self.CORS_ALLOW_NETWORK
145
- PUBLIC_MODEL = self.PUBLIC_MODEL
146
- LOCAL_MODEL = self.LOCAL_MODEL
147
- IS_PUBLIC_MODE = self.IS_PUBLIC_MODE
148
- keyring = self.keyring
149
- get_current_user = self.get_current_user
150
- get_user_api_key = self.get_user_api_key
151
-
152
- STATE = ModelRuntimeState()
153
- STATE._sync_globals() # initial no-warning; public API warns on explicit legacy syncs
154
-
155
- # Configured by server_app.configure_model_runtime during app assembly.
156
-
157
-
158
- def _env_bool(key: str, default: bool = False) -> bool:
159
- raw = os.getenv(key)
160
- if raw is None:
161
- return default
162
- return raw.strip().lower() in {"1", "true", "yes", "on"}
163
-
164
-
165
- def _download_allowed(allow_download: bool = False) -> bool:
166
- # Prefer STATE (the source of truth) over bare module global for internal logic.
167
- autoload = getattr(STATE, "AUTOLOAD_MODELS", AUTOLOAD_MODELS)
168
- configured = getattr(STATE, "ALLOW_MODEL_DOWNLOADS", ALLOW_MODEL_DOWNLOADS)
86
+
87
+ router: Any = None
88
+ APP_MODE: str = "local"
89
+ DEFAULT_HOST: str = "127.0.0.1"
90
+ DEFAULT_PORT: int = 4825
91
+ DATA_DIR: Path = field(default_factory=lambda: Path.home() / ".latticeai")
92
+ BASE_DIR: Path = field(default_factory=Path.cwd)
93
+ ENABLE_TELEGRAM: bool = False
94
+ ENABLE_GRAPH: bool = True
95
+ AUTOLOAD_MODELS: bool = False
96
+ MODEL_IDLE_UNLOAD_SECONDS: int = 0
97
+ ALLOW_MODEL_DOWNLOADS: bool = False
98
+ MODEL_DOWNLOAD_TIMEOUT: int = 300
99
+ ALLOW_LOCAL_MODELS: bool = True
100
+ REQUIRE_AUTH: bool = False
101
+ INVITE_GATE_ENABLED: bool = False
102
+ ALLOW_PLAINTEXT_API_KEYS: bool = False
103
+ CORS_ALLOW_NETWORK: bool = False
104
+ PUBLIC_MODEL: str = "openai:gpt-4o-mini"
105
+ LOCAL_MODEL: str = "mlx-community/gemma-4-12b-it-4bit"
106
+ IS_PUBLIC_MODE: bool = False
107
+ keyring: Any = None
108
+ get_current_user: Callable[[Any], Optional[str]] = _missing_current_user
109
+ get_user_api_key: Callable[[Optional[str], str], Optional[str]] = _missing_user_api_key
110
+
111
+
112
+ def create_model_runtime_state(**deps: Any) -> ModelRuntimeState:
113
+ """Create an immutable runtime dependency set with strict key validation."""
114
+
115
+ known = {item.name for item in fields(ModelRuntimeState)}
116
+ unknown = sorted(set(deps) - known)
117
+ if unknown:
118
+ raise TypeError(f"unknown model runtime dependencies: {', '.join(unknown)}")
119
+ return ModelRuntimeState(**deps)
120
+
121
+ def _download_allowed(
122
+ allow_download: bool = False, *, state: ModelRuntimeState
123
+ ) -> bool:
124
+ autoload = state.AUTOLOAD_MODELS
125
+ configured = state.ALLOW_MODEL_DOWNLOADS
169
126
  return bool(allow_download) or bool(configured) or bool(autoload)
170
127
 
171
128
 
172
129
  def _download_block(provider: str, model_name: str) -> None:
173
- raise HTTPException(
130
+ raise ModelRuntimeError(
174
131
  status_code=409,
175
132
  detail={
176
133
  "status": "unavailable",
@@ -188,7 +145,7 @@ def _download_block(provider: str, model_name: str) -> None:
188
145
 
189
146
 
190
147
  def _engine_install_block(engine: str) -> None:
191
- raise HTTPException(
148
+ raise ModelRuntimeError(
192
149
  status_code=409,
193
150
  detail={
194
151
  "status": "unavailable",
@@ -203,33 +160,15 @@ def _engine_install_block(engine: str) -> None:
203
160
  )
204
161
 
205
162
 
206
- def _missing_current_user(_request: Request) -> Optional[str]:
207
- return None
208
-
209
-
210
- def _missing_user_api_key(_email: Optional[str], _provider: str) -> Optional[str]:
211
- return None
212
-
213
-
214
- def configure_model_runtime(**deps) -> None:
215
- """Wire app-owned runtime dependencies without importing server_app.
163
+ def configure_model_runtime(**deps: Any) -> "ModelRuntimeService":
164
+ """Compatibility factory returning an isolated, bound runtime service.
216
165
 
217
- Explicit per-key assignment (no blanket globals().update) so wiring is
218
- auditable and side effects are visible. Preserves exact public module
219
- globals and prior behavior for all callers and shims.
220
- Now uses STATE class for clean decomp.
166
+ The historical function mutated process-wide module globals. Keeping the
167
+ import path while returning a service preserves practical construction
168
+ compatibility without ambient state or cross-application leakage.
221
169
  """
222
- for key, value in deps.items():
223
- if hasattr(STATE, key):
224
- setattr(STATE, key, value)
225
- elif key == "keyring":
226
- STATE.keyring = value
227
- elif key == "get_current_user":
228
- STATE.get_current_user = value
229
- elif key == "get_user_api_key":
230
- STATE.get_user_api_key = value
231
170
 
232
- STATE._sync_globals() # wiring path uses internal (no spurious deprecation in normal startup)
171
+ return ModelRuntimeService(create_model_runtime_state(**deps))
233
172
 
234
173
 
235
174
  # Catalog data + version-dedup helpers live in ``model_catalog``; re-exported
@@ -394,9 +333,9 @@ def ensure_lmstudio_model(model_name: str) -> Dict[str, object]:
394
333
  )
395
334
  except urllib.error.HTTPError as e:
396
335
  detail = e.read().decode("utf-8", errors="replace")[-2000:]
397
- raise HTTPException(status_code=500, detail=f"LM Studio 모델 다운로드 실패: {detail or e.reason}")
336
+ raise ModelRuntimeError(status_code=500, detail=f"LM Studio 모델 다운로드 실패: {detail or e.reason}")
398
337
  except Exception as e:
399
- raise HTTPException(status_code=500, detail=f"LM Studio 모델 다운로드 실패: {e}")
338
+ raise ModelRuntimeError(status_code=500, detail=f"LM Studio 모델 다운로드 실패: {e}")
400
339
 
401
340
  status = str(job.get("status") or "")
402
341
  job_id = str(job.get("job_id") or "")
@@ -412,10 +351,10 @@ def ensure_lmstudio_model(model_name: str) -> Dict[str, object]:
412
351
  if polled_status == "completed":
413
352
  break
414
353
  if polled_status == "failed":
415
- raise HTTPException(status_code=500, detail=f"LM Studio 모델 다운로드 실패: {polled}")
354
+ raise ModelRuntimeError(status_code=500, detail=f"LM Studio 모델 다운로드 실패: {polled}")
416
355
  time.sleep(2)
417
356
  else:
418
- raise HTTPException(status_code=408, detail="LM Studio 모델 다운로드 시간이 초과되었습니다.")
357
+ raise ModelRuntimeError(status_code=408, detail="LM Studio 모델 다운로드 시간이 초과되었습니다.")
419
358
 
420
359
  models = get_lmstudio_models(force=True)
421
360
  model_key = _find_lmstudio_model_key(model_name, models) or model_name
@@ -435,12 +374,12 @@ def ensure_lmstudio_model(model_name: str) -> Dict[str, object]:
435
374
  )
436
375
  except urllib.error.HTTPError as e:
437
376
  detail = e.read().decode("utf-8", errors="replace")[-2000:]
438
- raise HTTPException(status_code=500, detail=f"LM Studio 모델 로드 실패: {detail or e.reason}")
377
+ raise ModelRuntimeError(status_code=500, detail=f"LM Studio 모델 로드 실패: {detail or e.reason}")
439
378
  except Exception as e:
440
- raise HTTPException(status_code=500, detail=f"LM Studio 모델 로드 실패: {e}")
379
+ raise ModelRuntimeError(status_code=500, detail=f"LM Studio 모델 로드 실패: {e}")
441
380
 
442
381
  if str(loaded.get("status") or "") != "loaded":
443
- raise HTTPException(status_code=500, detail=f"LM Studio 모델 로드 실패: {loaded}")
382
+ raise ModelRuntimeError(status_code=500, detail=f"LM Studio 모델 로드 실패: {loaded}")
444
383
 
445
384
  return {
446
385
  "provider": "lmstudio",
@@ -545,7 +484,7 @@ def download_hf_model(
545
484
  progress_emit=None,
546
485
  ) -> Dict[str, object]:
547
486
  if importlib.util.find_spec("huggingface_hub") is None:
548
- raise HTTPException(status_code=400, detail="huggingface_hub가 없습니다. 먼저 MLX runtime 설치를 진행해 주세요.")
487
+ raise ModelRuntimeError(status_code=400, detail="huggingface_hub가 없습니다. 먼저 MLX runtime 설치를 진행해 주세요.")
549
488
 
550
489
  target_dir = hf_model_dir(repo_id)
551
490
  if hf_model_ready(repo_id, provider):
@@ -697,10 +636,10 @@ def download_hf_model(
697
636
  eta_seconds=0,
698
637
  ))
699
638
  except Exception as e:
700
- raise HTTPException(status_code=500, detail=f"{repo_id} 다운로드 실패: {str(e)[-2000:]}")
639
+ raise ModelRuntimeError(status_code=500, detail=f"{repo_id} 다운로드 실패: {str(e)[-2000:]}")
701
640
 
702
641
  if not hf_model_ready(repo_id, provider):
703
- raise HTTPException(status_code=500, detail=f"{repo_id} 다운로드가 완료되지 않았습니다. 모델 파일을 찾지 못했습니다.")
642
+ raise ModelRuntimeError(status_code=500, detail=f"{repo_id} 다운로드가 완료되지 않았습니다. 모델 파일을 찾지 못했습니다.")
704
643
 
705
644
  return {"model": repo_id, "path": str(target_dir), "cached": False}
706
645
 
@@ -733,9 +672,13 @@ def ensure_llamacpp_server(model_name: str) -> None:
733
672
  return _ensure_llamacpp_server(model_name)
734
673
 
735
674
 
736
- def _safe_engine_install_plan(engine: str) -> Optional[Dict[str, object]]:
675
+ def _safe_engine_install_plan(
676
+ engine: str,
677
+ *,
678
+ base_dir: Path,
679
+ ) -> Optional[Dict[str, object]]:
737
680
  try:
738
- return _engine_install_plan(engine)
681
+ return _engine_install_plan(engine, base_dir=base_dir)
739
682
  except Exception:
740
683
  return None
741
684
 
@@ -758,8 +701,13 @@ def engine_installed(engine: str) -> bool:
758
701
  return AsyncOpenAI is not None
759
702
  return False
760
703
 
761
- def engine_status() -> List[Dict]:
762
- r = getattr(STATE, "router", None) or router
704
+ def engine_status(
705
+ *,
706
+ state: ModelRuntimeState,
707
+ cloud_verify_cache: Optional[Dict[str, Dict[str, Any]]] = None,
708
+ ) -> List[Dict]:
709
+ r = state.router
710
+ verify_cache = cloud_verify_cache or {}
763
711
  cloud_models = r.detected_cloud_models() if r else []
764
712
  cloud_by_provider = {}
765
713
  for model in cloud_models:
@@ -861,7 +809,7 @@ def engine_status() -> List[Dict]:
861
809
  "installed": engine_installed("local_mlx"),
862
810
  "installable": True,
863
811
  "install_label": ENGINE_INSTALLERS["local_mlx"]["label"],
864
- "install_plan": _safe_engine_install_plan("local_mlx"),
812
+ "install_plan": _safe_engine_install_plan("local_mlx", base_dir=state.BASE_DIR),
865
813
  "models": mlx_models,
866
814
  },
867
815
  {
@@ -872,7 +820,7 @@ def engine_status() -> List[Dict]:
872
820
  "installed": ollama_installed,
873
821
  "installable": True,
874
822
  "install_label": ENGINE_INSTALLERS["ollama"]["label"],
875
- "install_plan": _safe_engine_install_plan("ollama"),
823
+ "install_plan": _safe_engine_install_plan("ollama", base_dir=state.BASE_DIR),
876
824
  "models": ollama_models,
877
825
  },
878
826
  ]
@@ -888,7 +836,11 @@ def engine_status() -> List[Dict]:
888
836
  "support_reason": support["reason"],
889
837
  "installable": support["supported"] and spec["id"] in ENGINE_INSTALLERS,
890
838
  "install_label": ENGINE_INSTALLERS.get(spec["id"], {}).get("label"),
891
- "install_plan": _safe_engine_install_plan(spec["id"]) if spec["id"] in ENGINE_INSTALLERS else None,
839
+ "install_plan": (
840
+ _safe_engine_install_plan(spec["id"], base_dir=state.BASE_DIR)
841
+ if spec["id"] in ENGINE_INSTALLERS
842
+ else None
843
+ ),
892
844
  "requires": spec["requires"],
893
845
  "models": (
894
846
  vllm_models if spec["id"] == "vllm"
@@ -903,7 +855,7 @@ def engine_status() -> List[Dict]:
903
855
  env_key = next((item.get("requires") for item in cloud_by_provider.get(provider, []) if item.get("requires")), None)
904
856
  provider_models = []
905
857
  for model in cloud_by_provider.get(provider, []):
906
- cache = CLOUD_VERIFY_CACHE.get(model.get("id"))
858
+ cache = verify_cache.get(model.get("id"))
907
859
  provider_models.append({
908
860
  **model,
909
861
  "verified": cache.get("ok") if cache else None,
@@ -917,17 +869,15 @@ def engine_status() -> List[Dict]:
917
869
  "installed": engine_installed(provider),
918
870
  "installable": True,
919
871
  "install_label": ENGINE_INSTALLERS[provider]["label"],
920
- "install_plan": _safe_engine_install_plan(provider),
872
+ "install_plan": _safe_engine_install_plan(provider, base_dir=state.BASE_DIR),
921
873
  "requires": env_key,
922
874
  "models": provider_models,
923
875
  })
924
876
  return engines
925
877
 
926
- def runtime_features() -> Dict:
927
- # Read from STATE object (central) for implementation; bare globals kept only
928
- # for external legacy consumers who import names directly from this module.
929
- s = STATE
930
- r = getattr(s, "router", None) or router
878
+ def runtime_features(*, state: ModelRuntimeState) -> Dict:
879
+ s = state
880
+ r = s.router
931
881
  return {
932
882
  "mode": s.APP_MODE,
933
883
  "public": s.IS_PUBLIC_MODE,
@@ -964,8 +914,17 @@ def runtime_features() -> Dict:
964
914
  },
965
915
  }
966
916
 
967
- def install_engine(engine: str, confirmation_token: Optional[str] = None) -> Dict:
968
- return _install_engine(engine, confirmation_token=confirmation_token)
917
+ def install_engine(
918
+ engine: str,
919
+ confirmation_token: Optional[str] = None,
920
+ *,
921
+ state: ModelRuntimeState,
922
+ ) -> Dict:
923
+ return _install_engine(
924
+ engine,
925
+ confirmation_token=confirmation_token,
926
+ base_dir=state.BASE_DIR,
927
+ )
969
928
 
970
929
 
971
930
  def _resolve_model_alias(model_id: str, engine: Optional[str] = None) -> str:
@@ -999,13 +958,13 @@ def normalize_local_model_request(model_id: str, engine: Optional[str] = None) -
999
958
  return model_id
1000
959
 
1001
960
 
1002
- def ensure_engine_ready(engine: str) -> Dict[str, object]:
961
+ def ensure_engine_ready(engine: str, *, state: ModelRuntimeState) -> Dict[str, object]:
1003
962
  engine = "local_mlx" if engine == "mlx" else engine
1004
963
  if engine not in ENGINE_INSTALLERS and engine not in OPENAI_COMPATIBLE_PROVIDERS:
1005
- raise HTTPException(status_code=400, detail=f"지원하지 않는 엔진입니다: {engine}")
964
+ raise ModelRuntimeError(status_code=400, detail=f"지원하지 않는 엔진입니다: {engine}")
1006
965
  support = engine_support_status(engine)
1007
966
  if not support["supported"]:
1008
- raise HTTPException(status_code=400, detail=str(support["reason"]))
967
+ raise ModelRuntimeError(status_code=400, detail=str(support["reason"]))
1009
968
 
1010
969
  if engine_installed(engine):
1011
970
  if engine == "local_mlx":
@@ -1013,12 +972,12 @@ def ensure_engine_ready(engine: str) -> Dict[str, object]:
1013
972
  return {"engine": engine, "installed": True, "installed_now": False}
1014
973
 
1015
974
  if engine not in ENGINE_INSTALLERS:
1016
- raise HTTPException(status_code=400, detail=f"{engine} 엔진 설치 방법이 등록되어 있지 않습니다.")
975
+ raise ModelRuntimeError(status_code=400, detail=f"{engine} 엔진 설치 방법이 등록되어 있지 않습니다.")
1017
976
 
1018
- result = install_engine(engine)
977
+ result = install_engine(engine, state=state)
1019
978
  if result.get("returncode") not in (0, None) or not engine_installed(engine):
1020
979
  detail = result.get("stderr") or result.get("stdout") or f"{engine} 설치에 실패했습니다."
1021
- raise HTTPException(status_code=500, detail=str(detail)[-2000:])
980
+ raise ModelRuntimeError(status_code=500, detail=str(detail)[-2000:])
1022
981
 
1023
982
  if engine == "local_mlx":
1024
983
  ensure_mlx_runtime()
@@ -1054,20 +1013,27 @@ async def _smoke_test_loaded_model(
1054
1013
  resolution: _ModelResolution,
1055
1014
  *,
1056
1015
  api_key_override: Optional[str] = None,
1016
+ state: ModelRuntimeState,
1057
1017
  ) -> Dict[str, object]:
1058
1018
  # Delegated to model_engines for server decomp
1059
1019
  from .model_engines import _smoke_test_loaded_model as _impl_smoke
1060
- return await _impl_smoke(resolution, api_key_override=api_key_override)
1020
+ return await _impl_smoke(
1021
+ resolution,
1022
+ api_key_override=api_key_override,
1023
+ model_router=state.router,
1024
+ )
1061
1025
 
1062
1026
 
1063
1027
  async def prepare_and_load_model(
1064
1028
  model_id: str,
1065
- request: Request,
1029
+ request: Any,
1066
1030
  engine: Optional[str] = None,
1067
1031
  user_email: Optional[str] = None,
1068
1032
  adapter_path: Optional[str] = None,
1069
1033
  draft_model_id: Optional[str] = None,
1070
1034
  allow_download: bool = False,
1035
+ *,
1036
+ state: ModelRuntimeState,
1071
1037
  ) -> Dict[str, object]:
1072
1038
  from .model_loading import prepare_and_load_model as _impl
1073
1039
 
@@ -1079,6 +1045,7 @@ async def prepare_and_load_model(
1079
1045
  adapter_path=adapter_path,
1080
1046
  draft_model_id=draft_model_id,
1081
1047
  allow_download=allow_download,
1048
+ runtime_state=state,
1082
1049
  )
1083
1050
 
1084
1051
 
@@ -1088,10 +1055,12 @@ def sse_event(event: str, data: Dict[str, object]) -> str:
1088
1055
 
1089
1056
  async def prepare_and_load_model_stream(
1090
1057
  model_id: str,
1091
- request: Request,
1058
+ request: Any,
1092
1059
  engine: Optional[str] = None,
1093
1060
  user_email: Optional[str] = None,
1094
1061
  allow_download: bool = False,
1062
+ *,
1063
+ state: ModelRuntimeState,
1095
1064
  ) -> AsyncIterator[str]:
1096
1065
  from .model_loading import prepare_and_load_model_stream as _impl
1097
1066
 
@@ -1101,11 +1070,11 @@ async def prepare_and_load_model_stream(
1101
1070
  engine=engine,
1102
1071
  user_email=user_email,
1103
1072
  allow_download=allow_download,
1073
+ runtime_state=state,
1104
1074
  ):
1105
1075
  yield event
1106
1076
 
1107
1077
 
1108
- CLOUD_VERIFY_CACHE: Dict[str, Dict] = {}
1109
1078
  CLOUD_VERIFY_TTL_SECONDS = 600
1110
1079
 
1111
1080
  async def _probe_cloud_model(model_ref: str) -> Dict[str, object]:
@@ -1140,9 +1109,15 @@ async def _probe_cloud_model(model_ref: str) -> Dict[str, object]:
1140
1109
  return {"ok": False, "reason": str(e)[:220]}
1141
1110
 
1142
1111
 
1143
- async def verify_cloud_models(force: bool = False, provider_filter: Optional[str] = None) -> Dict[str, Dict]:
1112
+ async def verify_cloud_models(
1113
+ force: bool = False,
1114
+ provider_filter: Optional[str] = None,
1115
+ *,
1116
+ state: ModelRuntimeState,
1117
+ cache: Dict[str, Dict[str, Any]],
1118
+ ) -> Dict[str, Dict]:
1144
1119
  now = time.time()
1145
- r = getattr(STATE, "router", None) or router
1120
+ r = state.router
1146
1121
  cloud_items = [item for item in (r.detected_cloud_models() if r else []) if item.get("tag") == "cloud"]
1147
1122
  if provider_filter:
1148
1123
  cloud_items = [item for item in cloud_items if item.get("provider") == provider_filter]
@@ -1150,17 +1125,108 @@ async def verify_cloud_models(force: bool = False, provider_filter: Optional[str
1150
1125
  results: Dict[str, Dict] = {}
1151
1126
  for item in cloud_items:
1152
1127
  model_ref = item["id"]
1153
- cached = CLOUD_VERIFY_CACHE.get(model_ref)
1128
+ cached = cache.get(model_ref)
1154
1129
  if not force and cached and (now - cached.get("ts", 0) <= CLOUD_VERIFY_TTL_SECONDS):
1155
1130
  results[model_ref] = cached
1156
1131
  continue
1157
1132
  if item.get("available") is False:
1158
1133
  record = {"ok": False, "reason": item.get("requires") or "API key missing", "ts": now}
1159
- CLOUD_VERIFY_CACHE[model_ref] = record
1134
+ cache[model_ref] = record
1160
1135
  results[model_ref] = record
1161
1136
  continue
1162
1137
  probe = await _probe_cloud_model(model_ref)
1163
1138
  record = {"ok": bool(probe.get("ok")), "reason": probe.get("reason", ""), "ts": now}
1164
- CLOUD_VERIFY_CACHE[model_ref] = record
1139
+ cache[model_ref] = record
1165
1140
  results[model_ref] = record
1166
1141
  return results
1142
+
1143
+
1144
+ @dataclass(slots=True)
1145
+ class ModelRuntimeService:
1146
+ """Bound model operations for one explicitly configured application.
1147
+
1148
+ All configuration and app-owned callables live on ``state``. Operational
1149
+ verification cache data belongs to this service instance, so creating a
1150
+ second ASGI app cannot inherit credentials, routers, or probe results from
1151
+ the first one.
1152
+ """
1153
+
1154
+ state: ModelRuntimeState
1155
+ _cloud_verify_cache: Dict[str, Dict[str, Any]] = field(default_factory=dict)
1156
+
1157
+ def runtime_features(self) -> Dict[str, Any]:
1158
+ return runtime_features(state=self.state)
1159
+
1160
+ def engine_status(self) -> List[Dict[str, Any]]:
1161
+ return engine_status(
1162
+ state=self.state,
1163
+ cloud_verify_cache=self._cloud_verify_cache,
1164
+ )
1165
+
1166
+ def install_engine(
1167
+ self,
1168
+ engine: str,
1169
+ confirmation_token: Optional[str] = None,
1170
+ ) -> Dict[str, Any]:
1171
+ return install_engine(
1172
+ engine,
1173
+ confirmation_token=confirmation_token,
1174
+ state=self.state,
1175
+ )
1176
+
1177
+ async def verify_cloud_models(
1178
+ self,
1179
+ force: bool = False,
1180
+ provider_filter: Optional[str] = None,
1181
+ ) -> Dict[str, Dict[str, Any]]:
1182
+ return await verify_cloud_models(
1183
+ force=force,
1184
+ provider_filter=provider_filter,
1185
+ state=self.state,
1186
+ cache=self._cloud_verify_cache,
1187
+ )
1188
+
1189
+ async def prepare_and_load_model(
1190
+ self,
1191
+ model_id: str,
1192
+ request: Any,
1193
+ engine: Optional[str] = None,
1194
+ user_email: Optional[str] = None,
1195
+ adapter_path: Optional[str] = None,
1196
+ draft_model_id: Optional[str] = None,
1197
+ allow_download: bool = False,
1198
+ ) -> Dict[str, object]:
1199
+ return await prepare_and_load_model(
1200
+ model_id,
1201
+ request,
1202
+ engine=engine,
1203
+ user_email=user_email,
1204
+ adapter_path=adapter_path,
1205
+ draft_model_id=draft_model_id,
1206
+ allow_download=allow_download,
1207
+ state=self.state,
1208
+ )
1209
+
1210
+ async def prepare_and_load_model_stream(
1211
+ self,
1212
+ model_id: str,
1213
+ request: Any,
1214
+ engine: Optional[str] = None,
1215
+ user_email: Optional[str] = None,
1216
+ allow_download: bool = False,
1217
+ ) -> AsyncIterator[str]:
1218
+ async for event in prepare_and_load_model_stream(
1219
+ model_id,
1220
+ request,
1221
+ engine=engine,
1222
+ user_email=user_email,
1223
+ allow_download=allow_download,
1224
+ state=self.state,
1225
+ ):
1226
+ yield event
1227
+
1228
+
1229
+ def build_model_runtime(**deps: Any) -> ModelRuntimeService:
1230
+ """Build the application's isolated model runtime service."""
1231
+
1232
+ return ModelRuntimeService(create_model_runtime_state(**deps))
@@ -217,7 +217,13 @@ class PReinforceGardener:
217
217
  folders.append({"name": folder, "description": desc, "files": files, "count": len(files)})
218
218
  return {"root": str(BRAIN_DIR), "folders": folders}
219
219
 
220
- def get_relevant_context(self, query: str, limit: int = 3) -> str:
220
+ def get_relevant_context(
221
+ self,
222
+ query: str,
223
+ limit: int = 3,
224
+ *,
225
+ allowed_workspaces: Any = None,
226
+ ) -> str:
221
227
  """질문과 관련된 정원 노트를 두뇌에서 검색해 컨텍스트로 반환.
222
228
 
223
229
  v4: 채팅마다 vault 전체를 rglob 하던 O(n) 스캔을 브레인 검색으로
@@ -225,7 +231,16 @@ class PReinforceGardener:
225
231
  """
226
232
  if self._kg is not None:
227
233
  try:
228
- matches = self._kg.search(query, max(limit * 4, 8)).get("matches", [])
234
+ scope_kwargs = (
235
+ {"allowed_workspaces": allowed_workspaces}
236
+ if allowed_workspaces is not None
237
+ else {}
238
+ )
239
+ matches = self._kg.search(
240
+ query,
241
+ max(limit * 4, 8),
242
+ **scope_kwargs,
243
+ ).get("matches", [])
229
244
  results = []
230
245
  for match in matches:
231
246
  meta = match.get("metadata") or {}
@@ -238,7 +253,11 @@ class PReinforceGardener:
238
253
  break
239
254
  return "\n\n".join(results)
240
255
  except Exception as exc:
241
- logging.debug("garden brain context failed, falling back to vault scan: %s", exc)
256
+ logging.debug("garden brain context failed: %s", exc)
257
+ if allowed_workspaces is not None:
258
+ return ""
259
+ elif allowed_workspaces is not None:
260
+ return ""
242
261
  return self._scan_vault_context(query, limit)
243
262
 
244
263
  def _scan_vault_context(self, query: str, limit: int = 3) -> str: