ltcai 9.0.0 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/README.md +88 -46
  2. package/auto_setup.py +7 -853
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +178 -2
  6. package/docs/COMMUNITY_AND_PLUGINS.md +4 -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/TRUST_MODEL.md +5 -2
  13. package/docs/WHY_LATTICE.md +15 -10
  14. package/docs/WORKFLOW_DESIGNER.md +22 -0
  15. package/docs/kg-schema.md +13 -2
  16. package/docs/mcp-tools.md +17 -6
  17. package/docs/privacy.md +19 -3
  18. package/docs/public-deploy.md +32 -3
  19. package/docs/security-model.md +46 -11
  20. package/lattice_brain/__init__.py +1 -1
  21. package/lattice_brain/archive.py +1 -6
  22. package/lattice_brain/context.py +66 -9
  23. package/lattice_brain/graph/_kg_fsutil.py +1 -5
  24. package/lattice_brain/graph/discovery_index.py +174 -20
  25. package/lattice_brain/graph/documents.py +44 -9
  26. package/lattice_brain/graph/ingest.py +47 -20
  27. package/lattice_brain/graph/provenance.py +13 -6
  28. package/lattice_brain/graph/retrieval.py +140 -39
  29. package/lattice_brain/graph/retrieval_docgen.py +37 -4
  30. package/lattice_brain/ingestion.py +4 -7
  31. package/lattice_brain/portability.py +28 -14
  32. package/lattice_brain/runtime/agent_runtime.py +5 -9
  33. package/lattice_brain/runtime/hooks.py +30 -13
  34. package/lattice_brain/runtime/multi_agent.py +27 -8
  35. package/lattice_brain/runtime/statuses.py +10 -0
  36. package/lattice_brain/utils.py +20 -2
  37. package/lattice_brain/workflow.py +1 -5
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/admin.py +1 -1
  40. package/latticeai/api/agent_registry.py +10 -8
  41. package/latticeai/api/agents.py +22 -3
  42. package/latticeai/api/auth.py +78 -16
  43. package/latticeai/api/browser.py +303 -34
  44. package/latticeai/api/chat.py +320 -850
  45. package/latticeai/api/chat_agent_http.py +356 -0
  46. package/latticeai/api/chat_contracts.py +54 -0
  47. package/latticeai/api/chat_documents.py +256 -0
  48. package/latticeai/api/chat_helpers.py +28 -1
  49. package/latticeai/api/chat_history.py +99 -0
  50. package/latticeai/api/chat_intents.py +316 -0
  51. package/latticeai/api/chat_stream.py +115 -0
  52. package/latticeai/api/computer_use.py +62 -9
  53. package/latticeai/api/health.py +9 -3
  54. package/latticeai/api/hooks.py +17 -6
  55. package/latticeai/api/knowledge_graph.py +78 -14
  56. package/latticeai/api/local_files.py +7 -2
  57. package/latticeai/api/mcp.py +102 -23
  58. package/latticeai/api/models.py +72 -33
  59. package/latticeai/api/network.py +5 -5
  60. package/latticeai/api/permissions.py +67 -26
  61. package/latticeai/api/plugins.py +21 -7
  62. package/latticeai/api/portability.py +5 -5
  63. package/latticeai/api/realtime.py +33 -5
  64. package/latticeai/api/setup.py +2 -2
  65. package/latticeai/api/static_routes.py +123 -24
  66. package/latticeai/api/tools.py +96 -14
  67. package/latticeai/api/workflow_designer.py +37 -0
  68. package/latticeai/api/workspace.py +2 -1
  69. package/latticeai/app_factory.py +110 -52
  70. package/latticeai/core/agent.py +50 -13
  71. package/latticeai/core/agent_prompts.py +5 -0
  72. package/latticeai/core/agent_registry.py +1 -5
  73. package/latticeai/core/config.py +23 -3
  74. package/latticeai/core/context_builder.py +21 -3
  75. package/latticeai/core/file_generation.py +451 -0
  76. package/latticeai/core/invitations.py +1 -4
  77. package/latticeai/core/io_utils.py +9 -1
  78. package/latticeai/core/legacy_compatibility.py +24 -3
  79. package/latticeai/core/marketplace.py +1 -1
  80. package/latticeai/core/plugins.py +15 -0
  81. package/latticeai/core/policy.py +1 -1
  82. package/latticeai/core/realtime.py +38 -15
  83. package/latticeai/core/sessions.py +7 -2
  84. package/latticeai/core/timeutil.py +10 -0
  85. package/latticeai/core/tool_registry.py +90 -18
  86. package/latticeai/core/users.py +1 -5
  87. package/latticeai/core/workspace_graph_trace.py +31 -5
  88. package/latticeai/core/workspace_memory.py +3 -1
  89. package/latticeai/core/workspace_os.py +18 -7
  90. package/latticeai/core/workspace_os_utils.py +0 -5
  91. package/latticeai/core/workspace_permissions.py +2 -1
  92. package/latticeai/core/workspace_plugins.py +1 -1
  93. package/latticeai/core/workspace_runs.py +14 -5
  94. package/latticeai/core/workspace_skills.py +1 -1
  95. package/latticeai/core/workspace_snapshots.py +2 -1
  96. package/latticeai/core/workspace_timeline.py +2 -1
  97. package/latticeai/integrations/telegram_bot.py +94 -43
  98. package/latticeai/models/router.py +130 -36
  99. package/latticeai/runtime/access_runtime.py +62 -4
  100. package/latticeai/runtime/automation_runtime.py +13 -7
  101. package/latticeai/runtime/brain_runtime.py +19 -7
  102. package/latticeai/runtime/chat_wiring.py +2 -0
  103. package/latticeai/runtime/config_runtime.py +58 -26
  104. package/latticeai/runtime/context_runtime.py +16 -4
  105. package/latticeai/runtime/hooks_runtime.py +1 -1
  106. package/latticeai/runtime/model_wiring.py +33 -5
  107. package/latticeai/runtime/namespace_runtime.py +62 -72
  108. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  109. package/latticeai/runtime/review_wiring.py +1 -1
  110. package/latticeai/runtime/router_registration.py +34 -1
  111. package/latticeai/runtime/security_runtime.py +110 -13
  112. package/latticeai/runtime/stages.py +27 -0
  113. package/latticeai/server_app.py +5 -1
  114. package/latticeai/services/architecture_readiness.py +74 -5
  115. package/latticeai/services/brain_automation.py +3 -26
  116. package/latticeai/services/chat_service.py +205 -15
  117. package/latticeai/services/local_knowledge.py +423 -0
  118. package/latticeai/services/memory_service.py +55 -21
  119. package/latticeai/services/model_engines.py +48 -33
  120. package/latticeai/services/model_errors.py +17 -0
  121. package/latticeai/services/model_loading.py +28 -25
  122. package/latticeai/services/model_runtime.py +228 -162
  123. package/latticeai/services/p_reinforce.py +22 -3
  124. package/latticeai/services/platform_runtime.py +83 -23
  125. package/latticeai/services/product_readiness.py +19 -17
  126. package/latticeai/services/review_queue.py +12 -0
  127. package/latticeai/services/router_context.py +1 -0
  128. package/latticeai/services/run_executor.py +4 -9
  129. package/latticeai/services/search_service.py +203 -28
  130. package/latticeai/services/tool_dispatch.py +28 -5
  131. package/latticeai/services/triggers.py +53 -4
  132. package/latticeai/services/upload_service.py +13 -2
  133. package/latticeai/setup/__init__.py +25 -0
  134. package/latticeai/setup/auto_setup.py +857 -0
  135. package/latticeai/setup/wizard.py +1264 -0
  136. package/latticeai/tools/__init__.py +278 -0
  137. package/{tools → latticeai/tools}/commands.py +67 -7
  138. package/{tools → latticeai/tools}/computer.py +1 -1
  139. package/{tools → latticeai/tools}/documents.py +1 -1
  140. package/{tools → latticeai/tools}/filesystem.py +3 -3
  141. package/latticeai/tools/knowledge.py +178 -0
  142. package/{tools → latticeai/tools}/local_files.py +1 -1
  143. package/{tools → latticeai/tools}/network.py +0 -1
  144. package/local_knowledge_api.py +4 -342
  145. package/package.json +22 -2
  146. package/scripts/brain_quality_eval.py +3 -1
  147. package/scripts/bump_version.py +3 -0
  148. package/scripts/capture_release_evidence.mjs +180 -0
  149. package/scripts/check_current_release_docs.mjs +142 -0
  150. package/scripts/check_i18n_literals.mjs +107 -31
  151. package/scripts/check_openapi_drift.mjs +56 -0
  152. package/scripts/export_openapi.py +67 -7
  153. package/scripts/i18n_literal_allowlist.json +22 -24
  154. package/scripts/run_integration_tests.mjs +72 -10
  155. package/scripts/validate_release_artifacts.py +49 -7
  156. package/scripts/wheel_smoke.py +5 -0
  157. package/setup_wizard.py +3 -1260
  158. package/src-tauri/Cargo.lock +1 -1
  159. package/src-tauri/Cargo.toml +1 -1
  160. package/src-tauri/tauri.conf.json +1 -1
  161. package/static/app/asset-manifest.json +11 -11
  162. package/static/app/assets/Act-C3dBrWE-.js +1 -0
  163. package/static/app/assets/Brain-DBYgdcjt.js +321 -0
  164. package/static/app/assets/Capture-Cf3hqRtN.js +1 -0
  165. package/static/app/assets/Library-CFfkNn3s.js +1 -0
  166. package/static/app/assets/System-BOurbT-v.js +1 -0
  167. package/static/app/assets/index-A3M9sElj.js +17 -0
  168. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  169. package/static/app/assets/primitives-DcUUmhdC.js +1 -0
  170. package/static/app/assets/textarea-BklR6zN4.js +1 -0
  171. package/static/app/index.html +2 -2
  172. package/static/sw.js +1 -1
  173. package/tools/__init__.py +21 -269
  174. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  175. package/latticeai/runtime/app_context_runtime.py +0 -13
  176. package/latticeai/runtime/tail_wiring.py +0 -21
  177. package/scripts/com.pts.claudecode.discord.plist +0 -31
  178. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  179. package/scripts/start-pts-claudecode-discord.sh +0 -51
  180. package/static/app/assets/Act-21lIXx2E.js +0 -1
  181. package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
  182. package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
  183. package/static/app/assets/Library-bFMtyni3.js +0 -1
  184. package/static/app/assets/System-K6krGCqn.js +0 -1
  185. package/static/app/assets/index-C4R3ws30.js +0 -17
  186. package/static/app/assets/index-ChSeOB02.css +0 -2
  187. package/static/app/assets/primitives-sQU3it5I.js +0 -1
  188. package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
  189. package/tools/knowledge.py +0 -95
@@ -5,11 +5,11 @@ import base64
5
5
  import os
6
6
  import socket
7
7
  import tempfile
8
- import time
9
8
  import zipfile
10
9
  import json
11
10
  from pathlib import Path
12
11
 
12
+ from latticeai.core.io_utils import atomic_write_json
13
13
  from latticeai.core.logging_safety import install_sensitive_log_filter, safe_log_text
14
14
  from latticeai.cli.runtime import _load_env_file
15
15
 
@@ -43,7 +43,7 @@ AGENT_WORKSPACE = Path(env_value("LATTICEAI_AGENT_ROOT", "agent_workspace"
43
43
  # Pending plan approvals: context_id → (chat_id, executing_model, reviewing_model)
44
44
  _bot_pending_plans: dict[str, dict] = {}
45
45
  MAX_TELEGRAM_FILE_BYTES = 45 * 1024 * 1024
46
- INVITE_CODE = env_value("LATTICEAI_INVITE_CODE", "gemma-lattice-ai")
46
+ INVITE_CODE = env_value("LATTICEAI_INVITE_CODE")
47
47
  PUBLIC_WEB_URL = env_value("LATTICEAI_PUBLIC_URL")
48
48
  DATA_DIR = Path(env_value("LATTICEAI_DATA_DIR", str(Path.home() / ".ltcai")))
49
49
  DATA_DIR.mkdir(parents=True, exist_ok=True)
@@ -55,45 +55,56 @@ logger = logging.getLogger(__name__)
55
55
  # ── Server session auth ───────────────────────────────────────────────────────
56
56
 
57
57
  def _get_server_session() -> str:
58
- """Read the most recent valid admin session from sessions.json (web login)."""
59
- explicit_token = env_value("LATTICEAI_SERVER_SESSION_TOKEN")
60
- if explicit_token:
61
- return explicit_token
62
- sessions_file = DATA_DIR / "sessions.json"
63
- users_file = DATA_DIR / "users.json"
64
- try:
65
- if not sessions_file.exists():
66
- return ""
67
- sessions = json.loads(sessions_file.read_text())
68
- admin_emails: set[str] = set()
69
- if users_file.exists():
70
- users = json.loads(users_file.read_text())
71
- admin_emails = {e for e, u in users.items() if u.get("role") == "admin" and not u.get("disabled")}
72
- now = time.time()
73
- # Pick the newest non-expired admin session
74
- best_token, best_ts = "", 0.0
75
- for token, entry in sessions.items():
76
- if len(token) == 64 and all(ch in "0123456789abcdef" for ch in token.lower()):
77
- continue
78
- email, created_at = entry[0], float(entry[1])
79
- if admin_emails and email not in admin_emails:
80
- continue
81
- if now - created_at > 7 * 86400:
82
- continue
83
- if created_at > best_ts:
84
- best_token, best_ts = token, created_at
85
- return best_token
86
- except Exception:
87
- return ""
58
+ """Return only the explicitly provisioned bot-to-server token.
59
+
60
+ Web sessions are hashed at rest and belong to interactive users. Scanning
61
+ ``sessions.json`` cannot recover a usable token and would couple the bot to
62
+ whichever administrator happened to be logged in most recently.
63
+ """
64
+ return env_value("LATTICEAI_SERVER_SESSION_TOKEN").strip()
88
65
 
89
66
  def _server_client(**kwargs) -> httpx.AsyncClient:
90
- """httpx client pre-loaded with the web session cookie."""
67
+ """Return a server client authenticated by an explicit bearer capability."""
91
68
  token = _get_server_session()
92
- cookies = {"session_token": token} if token else {}
93
- return httpx.AsyncClient(cookies=cookies, **kwargs)
69
+ if not token:
70
+ raise RuntimeError(
71
+ "LATTICEAI_SERVER_SESSION_TOKEN is required for Telegram server API calls."
72
+ )
73
+ headers = dict(kwargs.pop("headers", {}) or {})
74
+ headers["Authorization"] = f"Bearer {token}"
75
+ return httpx.AsyncClient(headers=headers, **kwargs)
94
76
 
95
77
  # ── Chat ID registry ─────────────────────────────────────────────────────────
96
78
 
79
+ def parse_allowed_chat_ids(raw: str) -> frozenset[int]:
80
+ """Parse a comma-separated Telegram chat-id allowlist."""
81
+ allowed: set[int] = set()
82
+ for value in str(raw or "").split(","):
83
+ value = value.strip()
84
+ if not value:
85
+ continue
86
+ try:
87
+ allowed.add(int(value))
88
+ except ValueError:
89
+ logger.warning(
90
+ "Invalid Telegram chat id ignored in allowlist: %r",
91
+ safe_log_text(value),
92
+ )
93
+ return frozenset(allowed)
94
+
95
+
96
+ def allowed_chat_ids() -> frozenset[int]:
97
+ """Return the configured allowlist; missing configuration denies all."""
98
+ return parse_allowed_chat_ids(env_value("LATTICEAI_TELEGRAM_ALLOWED_CHAT_IDS"))
99
+
100
+
101
+ def is_chat_allowed(chat_id) -> bool:
102
+ try:
103
+ return int(chat_id) in allowed_chat_ids()
104
+ except (TypeError, ValueError):
105
+ return False
106
+
107
+
97
108
  def load_chat_ids():
98
109
  try:
99
110
  if CHAT_IDS_FILE.exists():
@@ -105,19 +116,24 @@ def load_chat_ids():
105
116
 
106
117
  def save_chat_ids(chat_ids):
107
118
  try:
108
- CHAT_IDS_FILE.write_text(
109
- json.dumps({"chat_ids": sorted(chat_ids)}, ensure_ascii=False, indent=2),
110
- encoding="utf-8",
111
- )
119
+ atomic_write_json(CHAT_IDS_FILE, {"chat_ids": sorted(chat_ids)})
112
120
  except Exception as e:
113
121
  logger.error("텔레그램 채팅 목록 저장 실패: %s", safe_log_text(e))
114
122
 
115
123
  def register_chat_id(chat_id):
124
+ if not is_chat_allowed(chat_id):
125
+ logger.warning(
126
+ "허용되지 않은 텔레그램 채팅 등록 차단: %s",
127
+ safe_log_text(chat_id),
128
+ )
129
+ return False
130
+ chat_id = int(chat_id)
116
131
  chat_ids = load_chat_ids()
117
132
  if chat_id not in chat_ids:
118
133
  chat_ids.add(chat_id)
119
134
  save_chat_ids(chat_ids)
120
135
  logger.info("텔레그램 웹 미러링 대상 등록: %s", chat_id)
136
+ return True
121
137
 
122
138
  # ── Telegram API helpers ──────────────────────────────────────────────────────
123
139
 
@@ -205,7 +221,8 @@ def get_lan_ip():
205
221
  def get_web_url():
206
222
  if PUBLIC_WEB_URL:
207
223
  return PUBLIC_WEB_URL.rstrip("/")
208
- return f"http://{get_lan_ip()}:{SERVER_PORT}/?code={INVITE_CODE}"
224
+ base = f"http://{get_lan_ip()}:{SERVER_PORT}/"
225
+ return f"{base}?code={INVITE_CODE}" if INVITE_CODE else base
209
226
 
210
227
  def get_graph_url():
211
228
  if PUBLIC_WEB_URL:
@@ -217,7 +234,10 @@ def get_graph_url():
217
234
  async def broadcast_web_chat(role, text):
218
235
  if not TOKEN:
219
236
  return
220
- chat_ids = load_chat_ids()
237
+ # Old releases registered every sender. Intersect persisted recipients
238
+ # with the current allowlist so stale files cannot keep receiving mirrors.
239
+ allowed = allowed_chat_ids()
240
+ chat_ids = load_chat_ids() & set(allowed)
221
241
  if not chat_ids:
222
242
  return
223
243
  label = "사용자" if role == "user" else "Lattice AI"
@@ -730,6 +750,14 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
730
750
  if len(parts) != 3:
731
751
  return
732
752
  _, action, context_id = parts
753
+ pending = _bot_pending_plans.get(context_id)
754
+
755
+ # A callback is bound to the chat that received the plan. Even two allowed
756
+ # chats cannot approve or cancel each other's plan by replaying callback
757
+ # data.
758
+ if pending and int(pending.get("chat_id")) != int(chat_id):
759
+ await send_message(client, chat_id, "❌ 다른 채팅의 작업은 처리할 수 없습니다.")
760
+ return
733
761
  pending = _bot_pending_plans.pop(context_id, None)
734
762
 
735
763
  if action == "cancel" or not pending:
@@ -870,10 +898,17 @@ async def handle_command(client, chat_id, command: str, args: str):
870
898
  # ── Callback query handler ────────────────────────────────────────────────────
871
899
 
872
900
  async def handle_callback_query(client, callback_query):
873
- cq_id = callback_query["id"]
874
- chat_id = callback_query["message"]["chat"]["id"]
901
+ cq_id = callback_query.get("id", "")
902
+ chat_id = (
903
+ (callback_query.get("message") or {}).get("chat") or {}
904
+ ).get("id")
875
905
  data = callback_query.get("data", "")
876
906
 
907
+ if not is_chat_allowed(chat_id):
908
+ logger.warning("허용되지 않은 텔레그램 callback 차단: %s", safe_log_text(chat_id))
909
+ await answer_callback(client, cq_id, "허용되지 않은 채팅입니다.")
910
+ return
911
+
877
912
  await answer_callback(client, cq_id)
878
913
 
879
914
  if data == "cmd:status":
@@ -907,6 +942,16 @@ async def run_bot():
907
942
  if not TOKEN:
908
943
  logger.warning("LATTICEAI_TELEGRAM_BOT_TOKEN이 설정되지 않아 텔레그램 봇을 시작하지 않습니다.")
909
944
  return
945
+ if not allowed_chat_ids():
946
+ logger.error(
947
+ "LATTICEAI_TELEGRAM_ALLOWED_CHAT_IDS가 없어 텔레그램 봇을 시작하지 않습니다."
948
+ )
949
+ return
950
+ if not _get_server_session():
951
+ logger.error(
952
+ "LATTICEAI_SERVER_SESSION_TOKEN이 없어 텔레그램 봇을 시작하지 않습니다."
953
+ )
954
+ return
910
955
 
911
956
  logger.info("🚀 비동기 텔레그램 봇 모드 시작!")
912
957
  last_update_id = None
@@ -942,6 +987,12 @@ async def run_bot():
942
987
 
943
988
  msg = update["message"]
944
989
  chat_id = msg["chat"]["id"]
990
+ if not is_chat_allowed(chat_id):
991
+ logger.warning(
992
+ "허용되지 않은 텔레그램 메시지 차단: %s",
993
+ safe_log_text(chat_id),
994
+ )
995
+ continue
945
996
  register_chat_id(chat_id)
946
997
  text = msg.get("text", "")
947
998
  caption = msg.get("caption", "")
@@ -261,11 +261,13 @@ class LLMRouter:
261
261
 
262
262
  @property
263
263
  def current_model_id(self) -> Optional[str]:
264
- return self._current
264
+ with self._lock:
265
+ return self._current
265
266
 
266
267
  @property
267
268
  def loaded_model_ids(self) -> List[str]:
268
- return list(self._cache.keys())
269
+ with self._lock:
270
+ return list(self._cache.keys())
269
271
 
270
272
  def switch_model(self, model_id: str) -> None:
271
273
  with self._lock:
@@ -302,11 +304,12 @@ class LLMRouter:
302
304
  return unloaded
303
305
 
304
306
  def model_memory_policy(self) -> Dict[str, object]:
305
- return {
306
- "max_local_models": self._max_local_models,
307
- "loaded_count": len(self._cache),
308
- "last_used": dict(self._last_used),
309
- }
307
+ with self._lock:
308
+ return {
309
+ "max_local_models": self._max_local_models,
310
+ "loaded_count": len(self._cache),
311
+ "last_used": dict(self._last_used),
312
+ }
310
313
 
311
314
  def _touch(self, model_id: Optional[str] = None) -> None:
312
315
  model_id = model_id or self._current
@@ -478,7 +481,8 @@ class LLMRouter:
478
481
  return items
479
482
 
480
483
  def _is_cloud_current(self) -> bool:
481
- return bool(self._current and isinstance(self._cache.get(self._current), CloudModel))
484
+ with self._lock:
485
+ return bool(self._current and isinstance(self._cache.get(self._current), CloudModel))
482
486
 
483
487
  def _local_server_error_hint(self, cloud: CloudModel, error: Exception) -> str:
484
488
  raw = str(error)
@@ -532,28 +536,62 @@ class LLMRouter:
532
536
  loader_kind = str(cached[3]) if len(cached) > 3 else "mlx_vlm"
533
537
  return model, tokenizer, draft_model, loader_kind
534
538
 
535
- async def generate_as(self, model_id: str | None, message: str, context: Optional[str] = None, max_tokens: int = 4096, temperature: float = 0.2) -> str:
536
- """Generate using a specific model, temporarily switching if needed. Falls back to current model if model_id is None or not loaded."""
537
- if not model_id or model_id == self._current:
538
- return await self.generate(message, context, max_tokens, temperature)
539
- if model_id not in self._cache:
540
- raise ValueError(f"Model '{model_id}' is not loaded. Load it first via /models/load.")
541
- prev = self._current
542
- self._current = model_id
543
- try:
544
- return await self.generate(message, context, max_tokens, temperature)
545
- finally:
546
- self._current = prev
539
+ def _model_snapshot(self, model_id: Optional[str] = None) -> tuple[Optional[str], object | None]:
540
+ """Return an immutable request-scoped view of a loaded model.
547
541
 
548
- async def generate(self, message: str, context: Optional[str] = None, max_tokens: int = 4096, temperature: float = 0.2, image_data: Optional[str] = None) -> str:
549
- if not self._current:
542
+ Generation must never change ``_current``: that value is a UI/default
543
+ preference shared by every request. Capturing the cache entry while
544
+ holding the registry lock prevents concurrent requests from selecting
545
+ or restoring each other's models.
546
+ """
547
+ with self._lock:
548
+ selected = model_id or self._current
549
+ if not selected:
550
+ return None, None
551
+ cached = self._cache.get(selected)
552
+ if cached is None:
553
+ raise ValueError(f"Model '{selected}' is not loaded. Load it first via /models/load.")
554
+ self._touch(selected)
555
+ return selected, cached
556
+
557
+ async def generate_as(
558
+ self,
559
+ model_id: str | None,
560
+ message: str,
561
+ context: Optional[str] = None,
562
+ max_tokens: int = 4096,
563
+ temperature: float = 0.2,
564
+ image_data: Optional[str] = None,
565
+ ) -> str:
566
+ """Generate with a request-scoped model without changing the default."""
567
+ _selected, cached = self._model_snapshot(model_id)
568
+ if cached is None:
550
569
  return "No model."
551
- self._touch()
552
- cached = self._cache[self._current]
570
+ return await self._generate_cached(cached, message, context, max_tokens, temperature, image_data)
571
+
572
+ async def generate(
573
+ self,
574
+ message: str,
575
+ context: Optional[str] = None,
576
+ max_tokens: int = 4096,
577
+ temperature: float = 0.2,
578
+ image_data: Optional[str] = None,
579
+ ) -> str:
580
+ return await self.generate_as(None, message, context, max_tokens, temperature, image_data)
581
+
582
+ async def _generate_cached(
583
+ self,
584
+ cached: object,
585
+ message: str,
586
+ context: Optional[str],
587
+ max_tokens: int,
588
+ temperature: float,
589
+ image_data: Optional[str],
590
+ ) -> str:
553
591
  if isinstance(cached, CloudModel):
554
592
  return await self._cloud_generate(cached, message, context, max_tokens, temperature)
555
593
 
556
- model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(self._cache[self._current])
594
+ model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
557
595
  use_vlm = loader_kind == "mlx_vlm"
558
596
  prompt = (
559
597
  self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
@@ -596,18 +634,26 @@ class LLMRouter:
596
634
  raise RuntimeError(self._local_server_error_hint(cloud, e)) from e
597
635
  return normalize_branding(response.choices[0].message.content or "")
598
636
 
599
- async def stream_generate(self, message: str, context: Optional[str] = None, max_tokens: int = 4096, temperature: float = 0.2, image_data: Optional[str] = None) -> AsyncIterator[str]:
600
- if not self._current:
637
+ async def stream_generate_as(
638
+ self,
639
+ model_id: str | None,
640
+ message: str,
641
+ context: Optional[str] = None,
642
+ max_tokens: int = 4096,
643
+ temperature: float = 0.2,
644
+ image_data: Optional[str] = None,
645
+ ) -> AsyncIterator[str]:
646
+ """Stream with a request-scoped model without changing the default."""
647
+ _selected, cached = self._model_snapshot(model_id)
648
+ if cached is None:
601
649
  yield "No model."
602
650
  return
603
- self._touch()
604
- cached = self._cache[self._current]
605
651
  if isinstance(cached, CloudModel):
606
652
  async for chunk in self._cloud_stream_generate(cached, message, context, max_tokens, temperature):
607
653
  yield chunk
608
654
  return
609
655
 
610
- model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(self._cache[self._current])
656
+ model, tokenizer, draft_model, loader_kind = self._unpack_local_cache(cached)
611
657
  use_vlm = loader_kind == "mlx_vlm"
612
658
  prompt = (
613
659
  self._build_vlm_prompt(model, tokenizer, message, context, 1 if image_data else 0)
@@ -643,6 +689,19 @@ class LLMRouter:
643
689
  break
644
690
  yield normalize_branding(chunk)
645
691
 
692
+ async def stream_generate(
693
+ self,
694
+ message: str,
695
+ context: Optional[str] = None,
696
+ max_tokens: int = 4096,
697
+ temperature: float = 0.2,
698
+ image_data: Optional[str] = None,
699
+ ) -> AsyncIterator[str]:
700
+ async for chunk in self.stream_generate_as(
701
+ None, message, context, max_tokens, temperature, image_data
702
+ ):
703
+ yield chunk
704
+
646
705
  async def _cloud_stream_generate(self, cloud: CloudModel, message: str, context: Optional[str], max_tokens: int, temperature: float) -> AsyncIterator[str]:
647
706
  system = SYSTEM_PROMPT
648
707
  context = normalize_branding(context)
@@ -691,10 +750,27 @@ class LLMRouter:
691
750
  temperature: float = 0.3,
692
751
  ) -> str:
693
752
  """Generate a document using a specialized system prompt with graph context."""
694
- if not self._current:
753
+ return await self.generate_document_as(
754
+ None,
755
+ message,
756
+ system_prompt,
757
+ max_tokens=max_tokens,
758
+ temperature=temperature,
759
+ )
760
+
761
+ async def generate_document_as(
762
+ self,
763
+ model_id: str | None,
764
+ message: str,
765
+ system_prompt: str,
766
+ *,
767
+ max_tokens: int = 8192,
768
+ temperature: float = 0.3,
769
+ ) -> str:
770
+ """Generate a document with a request-scoped model."""
771
+ _selected, cached = self._model_snapshot(model_id)
772
+ if cached is None:
695
773
  return "No model loaded."
696
- self._touch()
697
- cached = self._cache[self._current]
698
774
 
699
775
  if isinstance(cached, CloudModel):
700
776
  return await self._cloud_generate_document(cached, message, system_prompt, max_tokens, temperature)
@@ -750,11 +826,29 @@ class LLMRouter:
750
826
  temperature: float = 0.3,
751
827
  ) -> AsyncIterator[str]:
752
828
  """Stream document generation with specialized system prompt."""
753
- if not self._current:
829
+ async for chunk in self.stream_generate_document_as(
830
+ None,
831
+ message,
832
+ system_prompt,
833
+ max_tokens=max_tokens,
834
+ temperature=temperature,
835
+ ):
836
+ yield chunk
837
+
838
+ async def stream_generate_document_as(
839
+ self,
840
+ model_id: str | None,
841
+ message: str,
842
+ system_prompt: str,
843
+ *,
844
+ max_tokens: int = 8192,
845
+ temperature: float = 0.3,
846
+ ) -> AsyncIterator[str]:
847
+ """Stream a document with a request-scoped model."""
848
+ _selected, cached = self._model_snapshot(model_id)
849
+ if cached is None:
754
850
  yield "No model loaded."
755
851
  return
756
- self._touch()
757
- cached = self._cache[self._current]
758
852
 
759
853
  if isinstance(cached, CloudModel):
760
854
  async for chunk in self._cloud_stream_document(cached, message, system_prompt, max_tokens, temperature):
@@ -20,9 +20,23 @@ def build_access_runtime(
20
20
  from latticeai.core.policy import normalize_role, require_capability
21
21
  from latticeai.core.users import normalize_email
22
22
 
23
+ # The historical loopback/no-auth profile represents its single human as
24
+ # an empty identity. Keep that storage/workspace compatibility contract,
25
+ # but project the identity as an owner at authorization boundaries. Never
26
+ # extend this trust to public or non-loopback bindings, even if an invalid
27
+ # caller constructs this runtime with ``require_auth=False`` directly.
28
+ externally_reachable = bool(
29
+ getattr(config, "is_public", False)
30
+ or getattr(config, "network_exposed", False)
31
+ )
32
+ trusted_local_owner = not require_auth and not externally_reachable
33
+ effective_require_auth = bool(require_auth or externally_reachable)
34
+
23
35
  def get_user_role(email: str, users: Optional[Dict] = None) -> str:
24
36
  users = users or load_users()
25
37
  identity = str(email or "")
38
+ if trusted_local_owner and not identity:
39
+ return "owner"
26
40
  normalized_email = normalize_email(identity)
27
41
  user = users.get(normalized_email) or users.get(identity) or next(
28
42
  (
@@ -46,25 +60,69 @@ def build_access_runtime(
46
60
  return auth[7:].strip()
47
61
  return request.cookies.get("session_token")
48
62
 
63
+ def active_session_email(identity: Optional[str], users: Dict) -> Optional[str]:
64
+ """Resolve a session identity only while its account remains active."""
65
+ if not identity:
66
+ return None
67
+ raw_identity = str(identity)
68
+ normalized_email = normalize_email(raw_identity)
69
+ matched_key: Optional[str] = None
70
+ user = users.get(normalized_email)
71
+ if isinstance(user, dict):
72
+ matched_key = normalized_email
73
+ else:
74
+ user = users.get(raw_identity)
75
+ if isinstance(user, dict):
76
+ matched_key = raw_identity
77
+ else:
78
+ for key, item in users.items():
79
+ if isinstance(item, dict) and item.get("id") == raw_identity:
80
+ matched_key = str(key)
81
+ user = item
82
+ break
83
+ # A session for a deleted account, malformed account record, or an
84
+ # explicitly disabled account is invalid immediately. This check is
85
+ # intentionally performed on every request so stale bearer/cookie
86
+ # tokens cannot retain user or administrator access.
87
+ if not matched_key or not isinstance(user, dict) or bool(user.get("disabled", False)):
88
+ return None
89
+ return normalize_email(matched_key)
90
+
49
91
  def get_current_user(request: request_type) -> Optional[str]:
50
92
  token = extract_bearer_token(request)
51
93
  if token:
52
- return get_session_email(token)
94
+ try:
95
+ return active_session_email(get_session_email(token), load_users())
96
+ except Exception:
97
+ # Account-store failures must fail closed rather than turning a
98
+ # stale session into an authenticated identity.
99
+ return None
53
100
  return None
54
101
 
55
102
  def require_user(request: request_type) -> str:
56
103
  email = get_current_user(request)
57
- if require_auth and not email:
104
+ if email:
105
+ # Optional authentication remains meaningful in local mode: a
106
+ # valid session keeps its real account identity instead of being
107
+ # collapsed into the anonymous Local User fallback.
108
+ return email
109
+ if trusted_local_owner:
110
+ # A no-auth loopback caller is the trusted, anonymous local owner.
111
+ # Returning the legacy empty identity keeps ownerless workspaces,
112
+ # shared local vaults, and Local User profile behavior compatible;
113
+ # get_user_role() supplies the explicit owner authorization role.
114
+ return ""
115
+ if effective_require_auth and not email:
58
116
  raise http_exception(status_code=401, detail="인증이 필요합니다.")
59
117
  return email or ""
60
118
 
61
119
  def require_admin(request: request_type) -> tuple[str, Dict]:
62
120
  users = load_users()
63
- if not require_auth:
121
+ if trusted_local_owner:
64
122
  return "", users
65
123
  token = extract_bearer_token(request)
66
124
  if token:
67
- email = get_session_email(token)
125
+ email = active_session_email(get_session_email(token), users)
68
126
  if email:
69
127
  role = get_user_role(email, users)
70
128
  try:
@@ -27,19 +27,25 @@ def build_automation_runtime(
27
27
  from latticeai.services.triggers import TriggerService
28
28
 
29
29
  review_queue = ReviewQueueService(store=store)
30
+
31
+ def _run_triggered_workflow(workflow_id: str, inputs: Dict[str, Any]) -> Dict[str, Any]:
32
+ trigger = (inputs or {}).get("__trigger__") or {}
33
+ return platform.run_workflow_by_id(
34
+ workflow_id,
35
+ trigger.get("user_email"),
36
+ trigger.get("workspace_id"),
37
+ with_agent=True,
38
+ inputs=inputs,
39
+ )
40
+
30
41
  trigger_service = TriggerService(
31
42
  store=store,
32
- run_workflow=lambda wf_id, inputs: platform.run_workflow_by_id(
33
- wf_id,
34
- None,
35
- None,
36
- with_agent=False,
37
- inputs=inputs,
38
- ),
43
+ run_workflow=_run_triggered_workflow,
39
44
  data_dir=data_dir,
40
45
  review_sink=review_queue,
41
46
  tz_name=tz_name,
42
47
  )
48
+
43
49
  def _memory_ingest(**kwargs):
44
50
  try:
45
51
  # Delegate to store's upsert (enriches memories + KG automatically via internal graph.ingest_event)
@@ -2,7 +2,17 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, Dict
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from latticeai.runtime.stages import RuntimeStage
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class BrainRuntime(RuntimeStage):
13
+ BRAIN_CORE: Any
14
+ KNOWLEDGE_GRAPH: Any
15
+ CONVERSATIONS: Any
6
16
 
7
17
 
8
18
  def build_brain_runtime(
@@ -12,7 +22,7 @@ def build_brain_runtime(
12
22
  enable_graph: bool,
13
23
  embedder: Any,
14
24
  storage_engine: Any,
15
- ) -> Dict[str, Any]:
25
+ ) -> BrainRuntime:
16
26
  """Construct Brain Core storage/conversation primitives behind one seam."""
17
27
 
18
28
  from lattice_brain import BrainCore, ConversationStore
@@ -33,9 +43,11 @@ def build_brain_runtime(
33
43
  else ConversationStore(data_dir / "knowledge_graph.sqlite")
34
44
  )
35
45
  conversations.import_legacy_json(history_file)
36
- return {
37
- "BRAIN_CORE": brain_core,
38
- "KNOWLEDGE_GRAPH": knowledge_graph,
39
- "CONVERSATIONS": conversations,
40
- }
46
+ return BrainRuntime(
47
+ BRAIN_CORE=brain_core,
48
+ KNOWLEDGE_GRAPH=knowledge_graph,
49
+ CONVERSATIONS=conversations,
50
+ )
51
+
41
52
 
53
+ __all__ = ["BrainRuntime", "build_brain_runtime"]
@@ -56,6 +56,7 @@ def build_interaction_contexts(
56
56
  install_mcp: Any,
57
57
  mcp_public_item: Any,
58
58
  hooks: Any,
59
+ workspace_service: Any,
59
60
  chat_context: Any,
60
61
  search_service: Any,
61
62
  allowed_workspaces_for: Any,
@@ -89,6 +90,7 @@ def build_interaction_contexts(
89
90
  install_mcp=install_mcp,
90
91
  mcp_public_item=mcp_public_item,
91
92
  hooks=hooks,
93
+ workspace_service=workspace_service,
92
94
  allowed_workspaces_for=allowed_workspaces_for,
93
95
  )
94
96
  interaction_router_context = InteractionRouterContext(