ltcai 3.6.0 → 4.0.1

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 (238) hide show
  1. package/README.md +39 -31
  2. package/docs/CHANGELOG.md +64 -0
  3. package/docs/REALTIME_COLLABORATION.md +3 -3
  4. package/docs/V3_FRONTEND.md +9 -8
  5. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  6. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +552 -0
  7. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  8. package/docs/kg-schema.md +51 -53
  9. package/docs/spec-vs-impl.md +10 -10
  10. package/kg_schema.py +2 -520
  11. package/knowledge_graph.py +37 -4629
  12. package/knowledge_graph_api.py +11 -127
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +16 -17
  15. package/latticeai/api/agents.py +20 -7
  16. package/latticeai/api/auth.py +46 -15
  17. package/latticeai/api/chat.py +112 -76
  18. package/latticeai/api/health.py +1 -1
  19. package/latticeai/api/hooks.py +1 -1
  20. package/latticeai/api/invitations.py +100 -0
  21. package/latticeai/api/knowledge_graph.py +139 -0
  22. package/latticeai/api/local_files.py +1 -1
  23. package/latticeai/api/mcp.py +23 -11
  24. package/latticeai/api/memory.py +1 -1
  25. package/latticeai/api/models.py +1 -1
  26. package/latticeai/api/network.py +81 -0
  27. package/latticeai/api/plugins.py +3 -6
  28. package/latticeai/api/realtime.py +5 -8
  29. package/latticeai/api/search.py +26 -2
  30. package/latticeai/api/security_dashboard.py +2 -3
  31. package/latticeai/api/setup.py +2 -2
  32. package/latticeai/api/static_routes.py +11 -16
  33. package/latticeai/api/tools.py +3 -0
  34. package/latticeai/api/ui_redirects.py +26 -0
  35. package/latticeai/api/workflow_designer.py +85 -6
  36. package/latticeai/api/workspace.py +93 -57
  37. package/latticeai/app_factory.py +1781 -0
  38. package/latticeai/brain/__init__.py +18 -0
  39. package/latticeai/brain/_kg_common.py +1123 -0
  40. package/latticeai/brain/context.py +213 -0
  41. package/latticeai/brain/conversations.py +236 -0
  42. package/latticeai/brain/discovery.py +1455 -0
  43. package/latticeai/brain/documents.py +218 -0
  44. package/latticeai/brain/identity.py +175 -0
  45. package/latticeai/brain/ingest.py +644 -0
  46. package/latticeai/brain/memory.py +102 -0
  47. package/latticeai/brain/network.py +205 -0
  48. package/latticeai/brain/projection.py +561 -0
  49. package/latticeai/brain/provenance.py +401 -0
  50. package/latticeai/brain/retrieval.py +1316 -0
  51. package/latticeai/brain/schema.py +640 -0
  52. package/latticeai/brain/store.py +216 -0
  53. package/latticeai/brain/write_master.py +225 -0
  54. package/latticeai/core/agent.py +31 -7
  55. package/latticeai/core/audit.py +0 -7
  56. package/latticeai/core/config.py +1 -1
  57. package/latticeai/core/context_builder.py +1 -2
  58. package/latticeai/core/enterprise.py +1 -1
  59. package/latticeai/core/graph_curator.py +2 -2
  60. package/latticeai/core/invitations.py +131 -0
  61. package/latticeai/core/marketplace.py +1 -1
  62. package/latticeai/core/mcp_registry.py +791 -0
  63. package/latticeai/core/model_compat.py +1 -1
  64. package/latticeai/core/model_resolution.py +0 -1
  65. package/latticeai/core/multi_agent.py +238 -4
  66. package/latticeai/core/policy.py +54 -0
  67. package/latticeai/core/realtime.py +65 -44
  68. package/latticeai/core/security.py +1 -1
  69. package/latticeai/core/sessions.py +66 -10
  70. package/latticeai/core/users.py +147 -0
  71. package/latticeai/core/workflow_engine.py +114 -2
  72. package/latticeai/core/workspace_os.py +477 -29
  73. package/latticeai/models/__init__.py +7 -0
  74. package/latticeai/models/router.py +779 -0
  75. package/latticeai/server_app.py +29 -1536
  76. package/latticeai/services/agent_runtime.py +243 -4
  77. package/latticeai/services/app_context.py +75 -14
  78. package/latticeai/services/ingestion.py +47 -0
  79. package/latticeai/services/kg_portability.py +33 -3
  80. package/latticeai/services/memory_service.py +39 -11
  81. package/latticeai/services/model_runtime.py +2 -5
  82. package/latticeai/services/platform_runtime.py +100 -23
  83. package/latticeai/services/run_executor.py +328 -0
  84. package/latticeai/services/search_service.py +17 -8
  85. package/latticeai/services/tool_dispatch.py +12 -2
  86. package/latticeai/services/triggers.py +241 -0
  87. package/latticeai/services/upload_service.py +37 -12
  88. package/latticeai/services/workspace_service.py +55 -16
  89. package/llm_router.py +29 -772
  90. package/ltcai_cli.py +1 -2
  91. package/mcp_registry.py +25 -788
  92. package/p_reinforce.py +124 -14
  93. package/package.json +10 -20
  94. package/scripts/bump_version.py +99 -0
  95. package/scripts/generate_diagrams.py +0 -1
  96. package/scripts/lint_v3.mjs +105 -18
  97. package/scripts/validate_release_artifacts.py +0 -1
  98. package/scripts/wheel_smoke.py +142 -0
  99. package/server.py +11 -7
  100. package/setup_wizard.py +1142 -0
  101. package/static/sw.js +81 -52
  102. package/static/v3/asset-manifest.json +33 -25
  103. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  104. package/static/v3/css/lattice.base.css +1 -1
  105. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  106. package/static/v3/css/lattice.components.css +1 -1
  107. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  108. package/static/v3/css/lattice.shell.css +1 -1
  109. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  110. package/static/v3/css/lattice.tokens.css +3 -0
  111. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  112. package/static/v3/css/lattice.views.css +2 -2
  113. package/static/v3/index.html +3 -4
  114. package/static/v3/js/{app.c541f955.js → app.c5c80c46.js} +1 -1
  115. package/static/v3/js/core/{api.33d6320e.js → api.ba0fbf14.js} +58 -1
  116. package/static/v3/js/core/api.js +57 -0
  117. package/static/v3/js/core/i18n.880e1fec.js +575 -0
  118. package/static/v3/js/core/i18n.js +575 -0
  119. package/static/v3/js/core/routes.37522821.js +101 -0
  120. package/static/v3/js/core/routes.js +71 -63
  121. package/static/v3/js/core/{shell.8c163e0e.js → shell.e3f6bbfa.js} +68 -39
  122. package/static/v3/js/core/shell.js +66 -37
  123. package/static/v3/js/core/{store.34ebd5e6.js → store.7b2aa044.js} +11 -1
  124. package/static/v3/js/core/store.js +11 -1
  125. package/static/v3/js/views/account.eff40715.js +143 -0
  126. package/static/v3/js/views/account.js +143 -0
  127. package/static/v3/js/views/activity.0d271ef9.js +67 -0
  128. package/static/v3/js/views/activity.js +67 -0
  129. package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
  130. package/static/v3/js/views/admin-users.js +4 -6
  131. package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
  132. package/static/v3/js/views/agents.js +35 -12
  133. package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
  134. package/static/v3/js/views/chat.js +23 -0
  135. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  136. package/static/v3/js/views/graph-canvas.js +509 -0
  137. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  138. package/static/v3/js/views/hybrid-search.js +1 -2
  139. package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.4d09c537.js} +60 -44
  140. package/static/v3/js/views/knowledge-graph.js +60 -44
  141. package/static/v3/js/views/network.52a4f181.js +97 -0
  142. package/static/v3/js/views/network.js +97 -0
  143. package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
  144. package/static/v3/js/views/planning.js +26 -5
  145. package/static/v3/js/views/runs.b63b2afa.js +144 -0
  146. package/static/v3/js/views/runs.js +144 -0
  147. package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
  148. package/static/v3/js/views/settings.js +7 -8
  149. package/static/v3/js/views/snapshots.6f5db095.js +135 -0
  150. package/static/v3/js/views/snapshots.js +135 -0
  151. package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
  152. package/static/v3/js/views/workflows.js +87 -2
  153. package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
  154. package/static/v3/js/views/workspace-admin.js +156 -0
  155. package/static/vendor/chart.umd.min.js +20 -0
  156. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  157. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  158. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  159. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  160. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  161. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  162. package/static/vendor/fonts/inter.css +44 -0
  163. package/static/vendor/icons/tabler-icons.min.css +4 -0
  164. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  165. package/static/vendor/marked.min.js +69 -0
  166. package/telegram_bot.py +1 -2
  167. package/tools/commands.py +4 -2
  168. package/tools/computer.py +1 -1
  169. package/tools/documents.py +1 -3
  170. package/tools/filesystem.py +0 -4
  171. package/tools/knowledge.py +1 -3
  172. package/tools/network.py +1 -3
  173. package/codex_telegram_bot.py +0 -195
  174. package/docs/assets/v3.4.0/agent-run.png +0 -0
  175. package/docs/assets/v3.4.0/agents.png +0 -0
  176. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  177. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  178. package/docs/assets/v3.4.0/chat.png +0 -0
  179. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  180. package/docs/assets/v3.4.0/files.png +0 -0
  181. package/docs/assets/v3.4.0/home.png +0 -0
  182. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  183. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  184. package/docs/assets/v3.4.0/local-agent.png +0 -0
  185. package/docs/assets/v3.4.0/memory.png +0 -0
  186. package/docs/assets/v3.4.0/settings.png +0 -0
  187. package/docs/assets/v3.4.0/vision-input.png +0 -0
  188. package/docs/assets/v3.4.0/workflows.png +0 -0
  189. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  190. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  191. package/docs/assets/v3.4.1/local-agent.png +0 -0
  192. package/docs/images/admin-dashboard.png +0 -0
  193. package/docs/images/architecture.png +0 -0
  194. package/docs/images/enterprise.png +0 -0
  195. package/docs/images/graph.png +0 -0
  196. package/docs/images/hero.gif +0 -0
  197. package/docs/images/knowledge-graph.png +0 -0
  198. package/docs/images/lattice-ai-demo.gif +0 -0
  199. package/docs/images/lattice-ai-hero.png +0 -0
  200. package/docs/images/logo.svg +0 -33
  201. package/docs/images/mobile-responsive.png +0 -0
  202. package/docs/images/model-recommendation.png +0 -0
  203. package/docs/images/onboarding.png +0 -0
  204. package/docs/images/organization.png +0 -0
  205. package/docs/images/pipeline.png +0 -0
  206. package/docs/images/screenshot-admin.png +0 -0
  207. package/docs/images/screenshot-chat.png +0 -0
  208. package/docs/images/screenshot-graph.png +0 -0
  209. package/docs/images/skills.png +0 -0
  210. package/docs/images/workspace-dark.png +0 -0
  211. package/docs/images/workspace-light.png +0 -0
  212. package/docs/images/workspace.png +0 -0
  213. package/requirements.txt +0 -16
  214. package/static/account.html +0 -115
  215. package/static/activity.html +0 -73
  216. package/static/admin.html +0 -488
  217. package/static/agents.html +0 -139
  218. package/static/chat.html +0 -844
  219. package/static/css/reference/account.css +0 -439
  220. package/static/css/reference/admin.css +0 -610
  221. package/static/css/reference/base.css +0 -1661
  222. package/static/css/reference/chat.css +0 -4623
  223. package/static/css/reference/graph.css +0 -1016
  224. package/static/css/responsive.css +0 -861
  225. package/static/graph.html +0 -124
  226. package/static/platform.css +0 -104
  227. package/static/plugins.html +0 -136
  228. package/static/scripts/account.js +0 -238
  229. package/static/scripts/admin.js +0 -1614
  230. package/static/scripts/chat.js +0 -5081
  231. package/static/scripts/graph.js +0 -1804
  232. package/static/scripts/platform.js +0 -64
  233. package/static/scripts/ux.js +0 -167
  234. package/static/scripts/workspace.js +0 -948
  235. package/static/v3/js/core/routes.2ce3815a.js +0 -93
  236. package/static/workflows.html +0 -146
  237. package/static/workspace.css +0 -1121
  238. package/static/workspace.html +0 -357
@@ -0,0 +1,1781 @@
1
+ """Lattice AI application factory.
2
+
3
+ ``create_app`` performs *all* construction that ``latticeai.server_app``
4
+ historically ran at import time: MLX/GPU device init, config parsing,
5
+ singleton construction (knowledge graph, workspace OS, registries, pipelines,
6
+ gardener) and router assembly. Importing this module — like importing
7
+ ``latticeai.server_app`` — has **no side effects**: nothing heavy is imported
8
+ and no file is created until ``create_app``/``build_runtime`` is called.
9
+
10
+ ``build_runtime`` returns the full constructed namespace (every name the
11
+ legacy module-level assembly exposed); ``latticeai.server_app`` proxies it
12
+ lazily via module ``__getattr__`` for backwards compatibility.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import threading
18
+ from typing import TYPE_CHECKING, Any, Dict, Optional
19
+
20
+ if TYPE_CHECKING: # imports for annotations only — keep module import light
21
+ from fastapi import FastAPI
22
+
23
+ from latticeai.core.config import Config
24
+
25
+
26
+ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
27
+ """The legacy ``server_app`` assembly, moved verbatim into function scope.
28
+
29
+ Heavy imports (mlx, the LLM router, knowledge graph, MCP registry, …) are
30
+ deliberately *inside* this function so that importing the module performs
31
+ no GPU init, no singleton construction, and no filesystem writes.
32
+ """
33
+ import asyncio
34
+ import hashlib
35
+ import json
36
+ import logging
37
+ import os
38
+ import re
39
+ import secrets
40
+ import threading
41
+ import subprocess
42
+ import sys
43
+ import time
44
+ from contextlib import asynccontextmanager
45
+ from pathlib import Path
46
+
47
+ try:
48
+ import mlx.core as mx
49
+ mx.set_default_device(mx.gpu)
50
+ print("✅ MLX Metal context initialized in main thread.")
51
+ except Exception as e:
52
+ print(f"⚠️ MLX Metal context unavailable: {e}")
53
+ mx = None
54
+ from typing import List
55
+
56
+ import uvicorn
57
+ from fastapi import FastAPI, HTTPException, Request
58
+ from fastapi.middleware.cors import CORSMiddleware
59
+ from fastapi.staticfiles import StaticFiles
60
+ from pydantic import BaseModel
61
+
62
+ from latticeai.models.router import LLMRouter, normalize_branding
63
+ from knowledge_graph import KnowledgeGraphStore, set_llm_router
64
+ from local_knowledge_api import LocalKnowledgeWatcher
65
+ from latticeai.core.security import (
66
+ hash_password,
67
+ verify_password,
68
+ host_is_loopback as _host_is_loopback_impl,
69
+ client_ip as _client_ip_impl,
70
+ configure_trusted_proxies as _configure_trusted_proxies,
71
+ bytes_match_extension as _bytes_match_extension_impl,
72
+ redact_secret_text as _redact_secret_text,
73
+ check_ip_rate_limit as _check_ip_rate_limit,
74
+ enforce_rate_limit as _enforce_rate_limit,
75
+ )
76
+ from latticeai.core.sessions import SessionStore as _SessionStore
77
+ from latticeai.core.audit import (
78
+ get_audit_log as _get_audit_log,
79
+ append_audit_event as _append_audit_event,
80
+ classify_sensitive_message as _classify_sensitive_message,
81
+ build_sensitivity_report as _build_sensitivity_report,
82
+ build_admin_audit_report as _build_admin_audit_report,
83
+ )
84
+ from latticeai.api.auth import create_auth_router
85
+ from latticeai.api.admin import create_admin_router
86
+ from latticeai.api.security_dashboard import create_security_router as _create_security_router
87
+ from latticeai.core.model_compat import list_cached_profiles as _list_compat_profiles
88
+ from latticeai.core.config import Config
89
+ from latticeai.core.workspace_os import (
90
+ WORKSPACE_OS_VERSION,
91
+ WorkspaceOSStore,
92
+ remove_skill_directory,
93
+ )
94
+ from latticeai.core.enterprise import (
95
+ capability_registry,
96
+ )
97
+ from latticeai.core.invitations import InvitationStore
98
+ from latticeai.core.policy import normalize_role, policy_matrix, require_capability
99
+ from latticeai.core.users import (
100
+ ensure_user_identity,
101
+ load_users_file,
102
+ migrate_knowledge_graph_identity,
103
+ normalize_email,
104
+ save_users_file,
105
+ user_id_for_email as _user_id_for_email,
106
+ )
107
+ from latticeai.services.app_context import AppContext
108
+ from latticeai.services.workspace_service import WorkspaceService
109
+ from latticeai.services.model_service import ModelService
110
+ from latticeai.services.chat_service import ChatService
111
+ from latticeai.services.search_service import SearchService
112
+ from latticeai.core.embedding_providers import resolve_embedder, resolve_embedding_profile
113
+ from latticeai.services.agent_runtime import AgentRuntime
114
+ from latticeai.services.model_runtime import (
115
+ CLOUD_VERIFY_TTL_SECONDS,
116
+ ENGINE_MODEL_CATALOG,
117
+ LOCAL_SERVER_PROCESSES,
118
+ MODEL_ENGINE_ALIASES,
119
+ configure_model_runtime,
120
+ download_hf_model,
121
+ engine_status,
122
+ filter_lower_family_versions,
123
+ install_engine,
124
+ local_binary,
125
+ normalize_local_model_request,
126
+ prepare_and_load_model,
127
+ prepare_and_load_model_stream,
128
+ runtime_features,
129
+ sse_event,
130
+ verify_cloud_models,
131
+ ensure_ollama_server,
132
+ )
133
+ from latticeai.api.workspace import create_workspace_router, _workspace_scope_from_request
134
+ from latticeai.api.health import create_health_router
135
+ # ── v2 Agentic Workspace Platform layers ─────────────────────────────────────
136
+ from latticeai.core.plugins import PluginRegistry
137
+ from latticeai.core.realtime import RealtimeBus
138
+ from latticeai.core.marketplace import TemplateCatalog
139
+ from latticeai.services.platform_runtime import PlatformRuntime
140
+ from latticeai.services.run_executor import RunExecutor
141
+ from latticeai.api.plugins import create_plugins_router
142
+ from latticeai.api.workflow_designer import create_workflow_designer_router
143
+ from latticeai.api.agents import create_agents_router
144
+ from latticeai.api.realtime import create_realtime_router
145
+ from latticeai.api.invitations import create_invitations_router
146
+ from latticeai.api.marketplace import create_marketplace_router
147
+ from latticeai.api.models import create_models_router
148
+ from latticeai.api.chat import create_chat_router
149
+ from latticeai.api.search import create_search_router
150
+ from latticeai.api.tools import create_tools_router
151
+ from latticeai.api.static_routes import create_static_routes_router
152
+ from latticeai.api.garden import create_garden_router
153
+ from latticeai.api.setup import create_setup_router
154
+ from latticeai.api.hooks import create_hooks_router
155
+ from latticeai.core.hooks import HooksRegistry
156
+ from latticeai.core.builtin_hooks import register_builtin_hook_runners
157
+ from latticeai.api.agent_registry import create_agent_registry_router
158
+ from latticeai.core.agent_registry import AgentRegistry
159
+ from latticeai.api.memory import create_memory_router
160
+ from latticeai.api.browser import create_browser_router
161
+ from latticeai.api.portability import create_portability_router
162
+ from latticeai.services.memory_service import MemoryService
163
+ from latticeai.services.ingestion import IngestionItem, IngestionPipeline
164
+ from latticeai.brain.conversations import ConversationStore
165
+ from latticeai.brain.context import ContextAssembler
166
+ from latticeai.brain.memory import BrainMemory
167
+ from latticeai.brain.identity import DeviceIdentity
168
+ from latticeai.brain.network import BrainNetwork
169
+ from latticeai.api.network import create_network_router
170
+ from latticeai.services.kg_portability import KGPortabilityService
171
+ # The aliased names below look unused but are part of the legacy
172
+ # ``server_app`` attribute surface: every local is exported via
173
+ # ``dict(locals())`` and reached through ``server_app.__getattr__``
174
+ # (tests import _agent_risk, _LOCAL_WRITE_BLOCKED_PREFIXES, …).
175
+ from latticeai.services.tool_dispatch import ( # noqa: F401
176
+ LOCAL_WRITE_BLOCKED_PREFIXES as _LOCAL_WRITE_BLOCKED_PREFIXES,
177
+ TOOL_GOVERNANCE,
178
+ TOOL_GOVERNANCE_DEFAULT as _TOOL_GOVERNANCE_DEFAULT,
179
+ agent_risk as _agent_risk,
180
+ check_tool_role as _check_tool_role,
181
+ configure_tool_dispatch,
182
+ get_tool_permission,
183
+ list_tool_permissions,
184
+ tool_response as _tool_response,
185
+ )
186
+ from latticeai.core.tool_registry import TOOL_CATALOG_BRIEF as _TOOL_CATALOG_BRIEF # noqa: F401
187
+ from latticeai.core.mcp_registry import (
188
+ _get_combined_registry,
189
+ _fetch_skills_marketplace, install_skill, SKILLS_DIR,
190
+ )
191
+ from p_reinforce import PReinforceGardener
192
+ from setup_wizard import get_recommendations, scan_environment
193
+ from tools import ensure_agent_root
194
+
195
+ try:
196
+ import keyring
197
+ except Exception:
198
+ keyring = None
199
+
200
+ from datetime import datetime
201
+
202
+ # ── App-level config — parsed once, in one place (latticeai.core.config) ──────
203
+ # The module-level names below are kept as a compatibility surface for the rest
204
+ # of server.py; all of them are now derived from a single CONFIG instance.
205
+ CONFIG = config if config is not None else Config.from_env()
206
+ APP_VERSION = WORKSPACE_OS_VERSION
207
+
208
+ # Forwarded headers (X-Forwarded-For / CF-Connecting-IP) are only honoured for
209
+ # IP rate limiting when the direct peer is one of these trusted proxies. Empty by
210
+ # default (local-first): the peer address is used and client-supplied headers are
211
+ # ignored, so per-IP rate limits cannot be spoofed.
212
+ _configure_trusted_proxies(CONFIG.trusted_proxies)
213
+
214
+ APP_MODE = CONFIG.app_mode
215
+ IS_PUBLIC_MODE = CONFIG.is_public
216
+ DEFAULT_HOST = CONFIG.host
217
+ DEFAULT_PORT = CONFIG.port
218
+ def _host_is_loopback(host: str) -> bool:
219
+ return _host_is_loopback_impl(host)
220
+
221
+ NETWORK_EXPOSED = CONFIG.network_exposed
222
+ ENABLE_TELEGRAM = CONFIG.enable_telegram
223
+ ENABLE_GRAPH = CONFIG.enable_graph
224
+ AUTOLOAD_MODELS = CONFIG.autoload_models
225
+ MODEL_IDLE_UNLOAD_SECONDS = CONFIG.model_idle_unload_seconds
226
+ ALLOW_LOCAL_MODELS = CONFIG.allow_local_models
227
+ REQUIRE_AUTH = CONFIG.require_auth
228
+ ALLOW_PLAINTEXT_API_KEYS = CONFIG.allow_plaintext_api_keys
229
+ CORS_ALLOW_NETWORK = CONFIG.cors_allow_network
230
+ CORS_EXTRA_ORIGINS = CONFIG.cors_extra_origins
231
+ PUBLIC_MODEL = CONFIG.public_model
232
+ LOCAL_MODEL = CONFIG.local_model
233
+ LOCAL_DRAFT_MODEL = CONFIG.local_draft_model
234
+
235
+ # ── SSO / OIDC config ─────────────────────────────────────────────────────────
236
+ SSO_DISCOVERY_URL = CONFIG.sso_discovery_url
237
+ SSO_CLIENT_ID = CONFIG.sso_client_id
238
+ SSO_CLIENT_SECRET = CONFIG.sso_client_secret
239
+ SSO_REDIRECT_URI = CONFIG.sso_redirect_uri
240
+ SSO_PROVIDER_NAME = CONFIG.sso_provider_name
241
+ _sso_discovery_cache: Optional[Dict] = None
242
+ _sso_discovery_cache_url: str = ""
243
+ _sso_states: Dict[str, float] = {} # state → timestamp (CSRF protection)
244
+
245
+ async def _get_sso_discovery() -> Optional[Dict]:
246
+ nonlocal _sso_discovery_cache, _sso_discovery_cache_url
247
+ settings = get_sso_settings()
248
+ discovery_url = settings.get("discovery_url", "")
249
+ if _sso_discovery_cache and _sso_discovery_cache_url == discovery_url:
250
+ return _sso_discovery_cache
251
+ if not discovery_url:
252
+ return None
253
+ try:
254
+ import httpx as _httpx
255
+ async with _httpx.AsyncClient() as c:
256
+ r = await c.get(discovery_url, timeout=10)
257
+ r.raise_for_status()
258
+ _sso_discovery_cache = r.json()
259
+ _sso_discovery_cache_url = discovery_url
260
+ except Exception as e:
261
+ logging.warning("SSO discovery failed: %s", e)
262
+ return None
263
+ return _sso_discovery_cache
264
+
265
+ # ── Password hashing — used directly from latticeai.core.security ──────────────
266
+ # (hash_password / verify_password are imported above; no local wrapper needed)
267
+ def verify_and_migrate_password(email: str, plain: str, stored: str, users: Dict) -> bool:
268
+ """평문 비밀번호를 투명하게 해시로 마이그레이션. 마이그레이션 발생 시 audit log 남김."""
269
+ if ":" in stored and len(stored) > 64:
270
+ return verify_password(plain, stored)
271
+ if plain == stored:
272
+ users[email]["password"] = hash_password(plain)
273
+ save_users(users)
274
+ try:
275
+ append_audit_event("password_migrated_from_plaintext", user_email=email)
276
+ except Exception as e:
277
+ logging.warning("audit log failed on password migration: %s", e)
278
+ logging.info("Migrated plaintext password to scrypt hash for %s", email)
279
+ return True
280
+ return False
281
+
282
+ # ── Session store — delegated to latticeai.core.sessions ──────────────────────
283
+ _SESSION_TTL = 60 * 60 * 24
284
+ _session_store = _SessionStore()
285
+
286
+ def _check_rate_limit(ip: str, action: str, max_calls: int, window_secs: float) -> None:
287
+ _check_ip_rate_limit(ip, action, max_calls=max_calls, window_secs=window_secs)
288
+
289
+ def _client_ip(request: Request) -> str:
290
+ return _client_ip_impl(request)
291
+
292
+ def user_id_for_email(email: Optional[str]) -> Optional[str]:
293
+ return _user_id_for_email(load_users(), email)
294
+
295
+ def create_session(email: str) -> str:
296
+ return _session_store.create(user_id_for_email(email) or email, email=email)
297
+
298
+ def get_session_email(token: str) -> Optional[str]:
299
+ return _session_store.get_email(token)
300
+
301
+ def get_session_user_id(token: str) -> Optional[str]:
302
+ return _session_store.get_subject(token)
303
+
304
+ def invalidate_session(token: str) -> None:
305
+ _session_store.invalidate(token)
306
+
307
+ # ── User Management Logic ──────────────────────────────────────────────────
308
+ BASE_DIR = Path(__file__).resolve().parent.parent
309
+ DATA_DIR = CONFIG.data_dir
310
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
311
+ STATIC_DIR = CONFIG.static_dir
312
+
313
+ USERS_FILE = DATA_DIR / "users.json"
314
+ HISTORY_FILE = DATA_DIR / "chat_history.json"
315
+ VPC_FILE = DATA_DIR / "vpc_config.json"
316
+ MCP_FILE = DATA_DIR / "mcp_installs.json"
317
+ AUDIT_FILE = DATA_DIR / "audit_log.json"
318
+ SSO_FILE = DATA_DIR / "sso_config.json"
319
+ # Resolve the configured embedding provider once at startup. Degrades to the
320
+ # offline hash fallback when the requested provider is unavailable, while
321
+ # recording the requested-vs-active provider for the Embeddings status surface.
322
+ try:
323
+ EMBEDDING_PROFILE = resolve_embedding_profile(CONFIG.embedding_profile)
324
+ except ValueError as exc:
325
+ logging.warning("Embedding profile ignored: %s", exc)
326
+ EMBEDDING_PROFILE = {}
327
+ _embedding_provider = CONFIG.embedding_provider
328
+ _embedding_model = CONFIG.embedding_model or str(EMBEDDING_PROFILE.get("model") or "")
329
+ _embedding_dim = CONFIG.embedding_dim or int(EMBEDDING_PROFILE.get("dimensions") or 0)
330
+ if CONFIG.embedding_profile and CONFIG.embedding_provider in {"", "hash", "local", "fallback"}:
331
+ _embedding_provider = str(EMBEDDING_PROFILE.get("provider") or CONFIG.embedding_provider)
332
+
333
+ EMBEDDER = resolve_embedder(
334
+ _embedding_provider,
335
+ model=_embedding_model,
336
+ base_url=CONFIG.embedding_base_url,
337
+ api_key=CONFIG.embedding_api_key,
338
+ dim=_embedding_dim,
339
+ timeout=CONFIG.embedding_timeout,
340
+ extra={"target": CONFIG.embedding_custom_target},
341
+ probe=_embedding_provider not in {"", "hash", "local", "fallback"},
342
+ )
343
+ if EMBEDDER.fell_back:
344
+ logging.warning("Embedding provider %s unavailable: %s", EMBEDDER.requested, EMBEDDER.detail)
345
+ KNOWLEDGE_GRAPH = KnowledgeGraphStore(
346
+ DATA_DIR / "knowledge_graph.sqlite",
347
+ DATA_DIR / "knowledge_graph_blobs",
348
+ embedder=EMBEDDER.provider,
349
+ ) if ENABLE_GRAPH else None
350
+ # ── v4 durable conversation store: unbounded episodic memory in the same
351
+ # SQLite file as the graph (kg_portability backup/restore covers it for
352
+ # free). Legacy chat_history.json is imported once, idempotently, and the
353
+ # file is left untouched on disk as the import source.
354
+ CONVERSATIONS = ConversationStore(DATA_DIR / "knowledge_graph.sqlite")
355
+ CONVERSATIONS.import_legacy_json(HISTORY_FILE)
356
+ # Hooks registry is constructed here (ahead of the watcher) so folder-watch
357
+ # reindexes can fire the pre_index/post_index lifecycle hooks.
358
+ HOOKS_REGISTRY = HooksRegistry(DATA_DIR / "hooks.json")
359
+ LOCAL_KG_WATCHER = LocalKnowledgeWatcher(lambda: KNOWLEDGE_GRAPH, hooks=HOOKS_REGISTRY) if ENABLE_GRAPH else None
360
+ # ── v2 Realtime bus: constructed first so the store can fan every timeline
361
+ # event into the realtime feed via a single additive sink (no per-call wiring).
362
+ REALTIME_BUS = RealtimeBus()
363
+ WORKSPACE_OS = WorkspaceOSStore(DATA_DIR, event_sink=REALTIME_BUS)
364
+ # Service layer (latticeai.services) wraps the store with scope/permission
365
+ # guardrails; routers and the app assembly share this single instance.
366
+ WORKSPACE_SERVICE = WorkspaceService(WORKSPACE_OS, resolve_user_id=user_id_for_email)
367
+ INVITATION_STORE = InvitationStore(DATA_DIR / "invitations.json")
368
+ # ── v2 Plugin SDK registry (extends skills; discovers plugins/<id>/plugin.json)
369
+ PLUGINS_DIR = Path(os.getenv("LATTICEAI_PLUGINS_DIR") or (BASE_DIR / "plugins"))
370
+ PLUGIN_REGISTRY = PluginRegistry(PLUGINS_DIR, store=WORKSPACE_OS)
371
+ TEMPLATE_CATALOG = TemplateCatalog()
372
+ # ── v3.2 platform registries: lifecycle hooks + agent registry, persisted under
373
+ # DATA_DIR so the /app Hooks and Agent Registry views read/write real state.
374
+ # (HOOKS_REGISTRY is constructed earlier, before the local-knowledge watcher.)
375
+ AGENT_REGISTRY = AgentRegistry(DATA_DIR / "agent_registry.json")
376
+ # Unified long-term memory platform fronting workspace memories, agent
377
+ # snapshots, conversation history, and the KG graph/vector index.
378
+ MEMORY_SERVICE = MemoryService(
379
+ store=WORKSPACE_OS,
380
+ data_dir=DATA_DIR,
381
+ knowledge_graph=KNOWLEDGE_GRAPH,
382
+ enable_graph=ENABLE_GRAPH,
383
+ history_file=HISTORY_FILE,
384
+ conversation_store=CONVERSATIONS,
385
+ )
386
+ # ── v3.6.0 unified ingestion pipeline: the single write-side seam into the
387
+ # Knowledge Graph. Every new source (web URL, browser tab, …) flows through this
388
+ # so pre_tool/post_tool hooks fire on ingestion and provenance is captured
389
+ # uniformly. Existing direct ingest callers keep working; new paths converge here.
390
+ INGESTION_PIPELINE = IngestionPipeline(
391
+ KNOWLEDGE_GRAPH,
392
+ hooks=HOOKS_REGISTRY,
393
+ enable_graph=ENABLE_GRAPH,
394
+ audit=lambda action, detail, user: append_audit_event(action, user_email=user, **detail),
395
+ )
396
+ # ── v3.6.0 Knowledge Graph portability: local export / import / backup / restore.
397
+ # The graph is the user's durable asset, so it must be portable with no cloud.
398
+ DEVICE_IDENTITY = DeviceIdentity(DATA_DIR)
399
+ KG_PORTABILITY = KGPortabilityService(
400
+ knowledge_graph=KNOWLEDGE_GRAPH,
401
+ data_dir=DATA_DIR,
402
+ enable_graph=ENABLE_GRAPH,
403
+ device_identity=DEVICE_IDENTITY,
404
+ )
405
+
406
+ def _require_graph():
407
+ if not ENABLE_GRAPH or KNOWLEDGE_GRAPH is None:
408
+ raise HTTPException(status_code=404, detail="지식 그래프가 비활성화되어 있습니다. LATTICEAI_ENABLE_GRAPH=true 설정 후 다시 시도해 주세요.")
409
+
410
+ class UserRegister(BaseModel):
411
+ email: str
412
+ password: str
413
+ name: str
414
+ nickname: str
415
+
416
+ class UserLogin(BaseModel):
417
+ email: str
418
+ password: str
419
+
420
+ class AdminUserUpdate(BaseModel):
421
+ role: Optional[str] = None
422
+ disabled: Optional[bool] = None
423
+
424
+ class VpcConfigUpdate(BaseModel):
425
+ provider: Optional[str] = None
426
+ region: Optional[str] = None
427
+ cidr_block: Optional[str] = None
428
+ private_subnets: Optional[List[str]] = None
429
+ endpoint: Optional[str] = None
430
+ vpn_status: Optional[str] = None
431
+ peering_status: Optional[str] = None
432
+ notes: Optional[str] = None
433
+
434
+ class SsoConfigUpdate(BaseModel):
435
+ enabled: Optional[bool] = None
436
+ provider_name: Optional[str] = None
437
+ discovery_url: Optional[str] = None
438
+ client_id: Optional[str] = None
439
+ client_secret: Optional[str] = None
440
+ redirect_uri: Optional[str] = None
441
+ scopes: Optional[str] = None
442
+
443
+ def _sso_env_defaults() -> Dict[str, object]:
444
+ return {
445
+ "enabled": bool(SSO_DISCOVERY_URL and SSO_CLIENT_ID and SSO_CLIENT_SECRET),
446
+ "provider_name": SSO_PROVIDER_NAME,
447
+ "discovery_url": SSO_DISCOVERY_URL,
448
+ "client_id": SSO_CLIENT_ID,
449
+ "client_secret": SSO_CLIENT_SECRET,
450
+ "redirect_uri": SSO_REDIRECT_URI,
451
+ "scopes": "openid email profile",
452
+ }
453
+
454
+ def load_sso_config() -> Dict[str, object]:
455
+ config = _sso_env_defaults()
456
+ if SSO_FILE.exists():
457
+ try:
458
+ data = json.loads(SSO_FILE.read_text(encoding="utf-8"))
459
+ if isinstance(data, dict):
460
+ config.update({k: v for k, v in data.items() if v is not None})
461
+ except Exception as e:
462
+ logging.warning("load_sso_config failed (using env/defaults): %s", e)
463
+ config["provider_name"] = str(config.get("provider_name") or "SSO")
464
+ config["discovery_url"] = str(config.get("discovery_url") or "")
465
+ config["client_id"] = str(config.get("client_id") or "")
466
+ config["client_secret"] = str(config.get("client_secret") or "")
467
+ config["redirect_uri"] = str(config.get("redirect_uri") or SSO_REDIRECT_URI)
468
+ config["scopes"] = str(config.get("scopes") or "openid email profile")
469
+ config["enabled"] = bool(config.get("enabled")) and bool(
470
+ config["discovery_url"] and config["client_id"] and config["client_secret"]
471
+ )
472
+ return config
473
+
474
+ def get_sso_settings() -> Dict[str, object]:
475
+ return load_sso_config()
476
+
477
+ def public_sso_config(config: Optional[Dict[str, object]] = None) -> Dict[str, object]:
478
+ cfg = config or get_sso_settings()
479
+ return {
480
+ "enabled": bool(cfg.get("enabled")),
481
+ "provider_name": cfg.get("provider_name") or "",
482
+ "discovery_url": cfg.get("discovery_url") or "",
483
+ "client_id": cfg.get("client_id") or "",
484
+ "redirect_uri": cfg.get("redirect_uri") or SSO_REDIRECT_URI,
485
+ "scopes": cfg.get("scopes") or "openid email profile",
486
+ "secret_configured": bool(cfg.get("client_secret")),
487
+ }
488
+
489
+ def save_sso_config(update: Dict[str, object]) -> Dict[str, object]:
490
+ nonlocal _sso_discovery_cache, _sso_discovery_cache_url
491
+ current = load_sso_config()
492
+ if update.get("client_secret") == "":
493
+ update.pop("client_secret", None)
494
+ current.update({k: v for k, v in update.items() if v is not None})
495
+ current["enabled"] = bool(current.get("enabled")) and bool(
496
+ current.get("discovery_url") and current.get("client_id") and current.get("client_secret")
497
+ )
498
+ SSO_FILE.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8")
499
+ _sso_discovery_cache = None
500
+ _sso_discovery_cache_url = ""
501
+ return current
502
+
503
+ # MCP/skill request models moved to latticeai.api.mcp (v1.3.0).
504
+ DEFAULT_VPC_CONFIG = {
505
+ "provider": "AWS",
506
+ "region": "ap-northeast-2",
507
+ "cidr_block": "10.42.0.0/16",
508
+ "private_subnets": ["10.42.10.0/24", "10.42.20.0/24"],
509
+ "endpoint": "ltcai-private.local",
510
+ "vpn_status": "standby",
511
+ "peering_status": "not_configured",
512
+ "notes": "로컬 MLX 브릿지를 프라이빗 서브넷 또는 VPN 뒤에서 운영할 때 쓰는 네트워크 프로필입니다.",
513
+ "updated_at": None,
514
+ }
515
+
516
+
517
+ def load_users():
518
+ users = load_users_file(USERS_FILE)
519
+ email_to_id = {
520
+ email: user.get("id")
521
+ for email, user in users.items()
522
+ if isinstance(user, dict) and user.get("id")
523
+ }
524
+ try:
525
+ migrate_knowledge_graph_identity(DATA_DIR / "knowledge_graph.sqlite", email_to_id)
526
+ except Exception as exc:
527
+ logging.warning("knowledge graph identity migration skipped: %s", exc)
528
+ try:
529
+ WORKSPACE_OS.migrate_workspace_identities(email_to_id)
530
+ except Exception as exc:
531
+ logging.warning("workspace identity migration skipped: %s", exc)
532
+ return users
533
+
534
+ def save_users(users):
535
+ save_users_file(USERS_FILE, users)
536
+
537
+ def load_vpc_config() -> Dict:
538
+ if not os.path.exists(VPC_FILE):
539
+ return DEFAULT_VPC_CONFIG.copy()
540
+ try:
541
+ with open(VPC_FILE, "r", encoding="utf-8") as f:
542
+ stored = json.load(f)
543
+ return {**DEFAULT_VPC_CONFIG, **stored}
544
+ except Exception as e:
545
+ logging.warning("load_vpc_config failed (using defaults): %s", e)
546
+ return DEFAULT_VPC_CONFIG.copy()
547
+
548
+ def save_vpc_config(config: Dict):
549
+ config["updated_at"] = datetime.now().isoformat()
550
+ with open(VPC_FILE, "w", encoding="utf-8") as f:
551
+ json.dump(config, f, ensure_ascii=False, indent=2)
552
+
553
+ def load_mcp_installs() -> Dict:
554
+ if not os.path.exists(MCP_FILE):
555
+ return {"installed": {}, "updated_at": None}
556
+ try:
557
+ with open(MCP_FILE, "r", encoding="utf-8") as f:
558
+ data = json.load(f)
559
+ if "installed" not in data:
560
+ data["installed"] = {}
561
+ return data
562
+ except Exception as e:
563
+ logging.warning("load_mcp_installs failed: %s", e)
564
+ return {"installed": {}, "updated_at": None}
565
+
566
+ def save_mcp_installs(data: Dict):
567
+ data["updated_at"] = datetime.now().isoformat()
568
+ with open(MCP_FILE, "w", encoding="utf-8") as f:
569
+ json.dump(data, f, ensure_ascii=False, indent=2)
570
+
571
+ def mcp_public_item(item: Dict, installed_state: Dict) -> Dict:
572
+ state = installed_state.get(item["id"]) or {}
573
+ installed = item["install_mode"] in {"builtin", "bundled"} or bool(state.get("installed"))
574
+ connector_pending = item["install_mode"] == "connector" and not state.get("authenticated")
575
+ authenticated = item["install_mode"] != "connector" or bool(state.get("authenticated"))
576
+ return {
577
+ "id": item["id"],
578
+ "name": item["name"],
579
+ "category": item.get("category", ""),
580
+ "install_mode": item["install_mode"],
581
+ "description": item.get("description", ""),
582
+ "capabilities": item.get("capabilities", []),
583
+ "connector_url": item.get("connector_url"),
584
+ "external_url": item.get("external_url"),
585
+ "package": item.get("package"),
586
+ "homepage": item.get("homepage"),
587
+ "source": item.get("source", "local"),
588
+ "installed": installed,
589
+ "status": state.get("status") or ("active" if installed and not connector_pending else "needs_auth" if connector_pending else "available"),
590
+ "authenticated": authenticated,
591
+ "updated_at": state.get("updated_at"),
592
+ }
593
+
594
+ async def recommend_mcps(query: str, limit: int = 5) -> List[Dict]:
595
+ text = (query or "").lower()
596
+ installed = load_mcp_installs().get("installed", {})
597
+ registry = await _get_combined_registry()
598
+ scored = []
599
+ for item in registry:
600
+ score = 0
601
+ hits = []
602
+ for keyword in item.get("keywords", []):
603
+ if keyword.lower() in text:
604
+ score += 3 if len(keyword) > 2 else 1
605
+ hits.append(keyword)
606
+ # description 키워드 매칭 (remote 항목 보완)
607
+ if not hits and text:
608
+ desc_words = item.get("description", "").lower().split()
609
+ for word in text.split():
610
+ if len(word) > 2 and word in desc_words:
611
+ score += 1
612
+ hits.append(word)
613
+ if item["id"] == "filesystem" and any(word in text for word in ["만들", "구현", "build", "deploy", "코드", "앱"]):
614
+ score += 2
615
+ if score:
616
+ public = mcp_public_item(item, installed)
617
+ public["score"] = score
618
+ public["matched_keywords"] = hits[:6]
619
+ scored.append(public)
620
+ if not scored:
621
+ fallback_ids = ["filesystem", "browser", "documents"]
622
+ scored = [
623
+ {**mcp_public_item(item, installed), "score": 1, "matched_keywords": []}
624
+ for item in registry
625
+ if item["id"] in fallback_ids
626
+ ]
627
+ return sorted(scored, key=lambda item: item["score"], reverse=True)[: max(1, min(limit, 24))]
628
+
629
+ async def install_mcp(mcp_id: str) -> Dict:
630
+ registry = await _get_combined_registry()
631
+ item = next((entry for entry in registry if entry["id"] == mcp_id), None)
632
+ if not item:
633
+ raise HTTPException(status_code=404, detail="MCP를 찾을 수 없습니다.")
634
+ data = load_mcp_installs()
635
+ state = data.setdefault("installed", {})
636
+ status = "active"
637
+ message = "MCP가 활성화되었습니다."
638
+ if item["install_mode"] == "connector":
639
+ status = "needs_auth"
640
+ message = "커넥터 인증이 필요합니다. Codex 앱의 connector 설정에서 계정을 연결하면 바로 사용할 수 있습니다."
641
+ elif item["install_mode"] == "pip":
642
+ packages = item.get("pip_packages") or []
643
+ for pkg in packages:
644
+ completed = subprocess.run(
645
+ [sys.executable, "-m", "pip", "install", "--upgrade", pkg],
646
+ capture_output=True, text=True, timeout=900, check=False,
647
+ )
648
+ if completed.returncode != 0:
649
+ raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
650
+ message = f"필수 패키지 설치 완료: {', '.join(packages)}"
651
+ elif item["install_mode"] == "pypi":
652
+ pkg = item.get("package", "")
653
+ version = item.get("package_version")
654
+ pkg_str = f"{pkg}=={version}" if version else pkg
655
+ completed = subprocess.run(
656
+ [sys.executable, "-m", "pip", "install", pkg_str],
657
+ capture_output=True, text=True, timeout=300, check=False,
658
+ )
659
+ if completed.returncode != 0:
660
+ raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
661
+ message = f"pip 패키지 설치 완료: {pkg_str}"
662
+ elif item["install_mode"] == "npm":
663
+ pkg = item.get("package", "")
664
+ version = item.get("package_version")
665
+ pkg_str = f"{pkg}@{version}" if version else pkg
666
+ completed = subprocess.run(
667
+ ["npm", "install", "-g", pkg_str],
668
+ capture_output=True, text=True, timeout=300, check=False,
669
+ )
670
+ if completed.returncode != 0:
671
+ raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or f"{pkg} 설치 실패")
672
+ message = f"npm 패키지 설치 완료: {pkg_str}"
673
+ state[mcp_id] = {
674
+ "installed": True,
675
+ "status": status,
676
+ "authenticated": item["install_mode"] != "connector",
677
+ "updated_at": datetime.now().isoformat(),
678
+ }
679
+ save_mcp_installs(data)
680
+ public = mcp_public_item(item, state)
681
+ public["message"] = message
682
+ return public
683
+
684
+ _history_lock = threading.Lock()
685
+
686
+ def get_audit_log() -> List[Dict]:
687
+ return _get_audit_log(AUDIT_FILE)
688
+
689
+ def append_audit_event(event_type: str, **payload) -> None:
690
+ _append_audit_event(AUDIT_FILE, event_type, **payload)
691
+
692
+ def save_to_history(
693
+ role: str,
694
+ message: str,
695
+ user_email: Optional[str] = None,
696
+ user_nickname: Optional[str] = None,
697
+ source: Optional[str] = None,
698
+ conversation_id: Optional[str] = None,
699
+ ):
700
+ try:
701
+ message = redact_secret_text(message)
702
+ if role == "assistant":
703
+ message = normalize_branding(message)
704
+ item = {"role": role, "content": message, "timestamp": datetime.now().isoformat()}
705
+ if user_email:
706
+ item["user_email"] = user_email
707
+ if user_nickname:
708
+ item["user_nickname"] = user_nickname
709
+ if source:
710
+ item["source"] = source
711
+ if conversation_id:
712
+ item["conversation_id"] = conversation_id
713
+ sensitive = classify_sensitive_message(item, -1)
714
+ append_audit_event(
715
+ "chat_message",
716
+ role=role,
717
+ user_email=user_email,
718
+ user_nickname=user_nickname,
719
+ source=source,
720
+ conversation_id=conversation_id,
721
+ content_preview=sensitive.get("preview"),
722
+ content_chars=len(message or ""),
723
+ sensitivity=sensitive.get("sensitivity"),
724
+ sensitive_labels=sensitive.get("labels") or [],
725
+ )
726
+ # v4: conversations are durable episodic memory — unbounded SQLite
727
+ # store (the 50-message chat_history.json cap is dead).
728
+ CONVERSATIONS.append(item)
729
+ try:
730
+ if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
731
+ # v4: chat messages enter the brain through the unified
732
+ # ingestion pipeline (provenance + hook lifecycle), not by
733
+ # bypassing it with a direct store call.
734
+ INGESTION_PIPELINE.ingest(
735
+ IngestionItem(
736
+ source_type="chat_message",
737
+ text=message,
738
+ owner=user_email,
739
+ conversation_id=conversation_id,
740
+ metadata={
741
+ "role": role,
742
+ "user_nickname": user_nickname,
743
+ "source": source,
744
+ "raw": item,
745
+ },
746
+ ),
747
+ user_email=user_email,
748
+ )
749
+ except Exception as graph_error:
750
+ logging.warning("knowledge graph message ingest failed: %s", graph_error)
751
+ except Exception as e:
752
+ logging.warning("save_to_history failed: %s", e)
753
+
754
+ def redact_secret_text(text: str) -> str:
755
+ return _redact_secret_text(text)
756
+
757
+ def get_history():
758
+ try:
759
+ return CONVERSATIONS.history()
760
+ except Exception as e:
761
+ logging.warning("get_history failed: %s", e)
762
+ return []
763
+
764
+ # Chat service seam: behaviour-preserving façade for history access and
765
+ # Workspace-OS answer-trace recording used by the (unchanged) streaming chat path.
766
+ CHAT_SERVICE = ChatService(store=WORKSPACE_OS, get_history=get_history)
767
+
768
+ def conversation_title(item: Dict) -> str:
769
+ content = str(item.get("content") or "").strip()
770
+ content = re.sub(r"\s+", " ", content)
771
+ return content[:48] or "새 대화"
772
+
773
+ def group_history_conversations(history: Optional[List[Dict]] = None) -> List[Dict]:
774
+ history = history if history is not None else get_history()
775
+ conversations: Dict[str, Dict] = {}
776
+ order: List[str] = []
777
+
778
+ for index, item in enumerate(history):
779
+ conv_id = item.get("conversation_id")
780
+ if not conv_id:
781
+ conv_id = "legacy-previous-history"
782
+
783
+ if conv_id not in conversations:
784
+ conversations[conv_id] = {
785
+ "id": conv_id,
786
+ "title": "이전 대화 기록" if conv_id == "legacy-previous-history" else conversation_title(item),
787
+ "created_at": item.get("timestamp"),
788
+ "updated_at": item.get("timestamp"),
789
+ "message_count": 0,
790
+ "last_message": "",
791
+ "source": item.get("source"),
792
+ }
793
+ order.append(conv_id)
794
+
795
+ conv = conversations[conv_id]
796
+ conv["message_count"] += 1
797
+ conv["updated_at"] = item.get("timestamp") or conv.get("updated_at")
798
+ conv["last_message"] = conversation_title(item)
799
+ if conv_id != "legacy-previous-history" and item.get("role") == "user" and (not conv.get("title") or conv["title"] == "새 대화"):
800
+ conv["title"] = conversation_title(item)
801
+
802
+ return sorted((conversations[key] for key in order), key=lambda item: item.get("updated_at") or "", reverse=True)
803
+
804
+ def get_conversation_messages(conversation_id: str) -> List[Dict]:
805
+ history = get_history()
806
+ if conversation_id == "legacy-previous-history":
807
+ return [item for item in history if not item.get("conversation_id")]
808
+ return [item for item in history if item.get("conversation_id") == conversation_id]
809
+
810
+ def clear_history(keep_last: int = 0) -> Dict:
811
+ return CONVERSATIONS.clear_all(keep_last=keep_last)
812
+
813
+ def clear_conversation(conversation_id: str, started_at: Optional[str] = None) -> Dict:
814
+ return CONVERSATIONS.clear_conversation(conversation_id, started_at=started_at)
815
+
816
+ def get_user_role(email: str, users: Optional[Dict] = None) -> str:
817
+ users = users or load_users()
818
+ identity = str(email or "")
819
+ normalized_email = normalize_email(identity)
820
+ user = users.get(normalized_email) or users.get(identity) or next(
821
+ (
822
+ item for item in users.values()
823
+ if isinstance(item, dict) and item.get("id") == identity
824
+ ),
825
+ {},
826
+ )
827
+ if isinstance(user, dict) and user.get("role"):
828
+ return normalize_role(user["role"])
829
+ admin_emails = {normalize_email(item) for item in CONFIG.admin_emails}
830
+ if normalized_email in admin_emails:
831
+ return "admin"
832
+ first_email = next(iter(users), None)
833
+ return "admin" if first_email == normalized_email else "user"
834
+
835
+ def _extract_bearer_token(request: Request) -> Optional[str]:
836
+ auth = request.headers.get("Authorization", "")
837
+ if auth.startswith("Bearer "):
838
+ return auth[7:].strip()
839
+ return request.cookies.get("session_token")
840
+
841
+ def get_current_user(request: Request) -> Optional[str]:
842
+ token = _extract_bearer_token(request)
843
+ if token:
844
+ return get_session_email(token)
845
+ return None
846
+
847
+ def require_user(request: Request) -> str:
848
+ email = get_current_user(request)
849
+ if REQUIRE_AUTH and not email:
850
+ raise HTTPException(status_code=401, detail="인증이 필요합니다.")
851
+ return email or ""
852
+
853
+
854
+ # ── Rate limiting & file validation — delegated to latticeai.core.security ────
855
+ _RATE_LIMIT_ENABLED = CONFIG.rate_limit_enabled
856
+
857
+ def enforce_rate_limit(email: str, bucket_key: str) -> None:
858
+ _enforce_rate_limit(email, bucket_key, enabled=_RATE_LIMIT_ENABLED)
859
+
860
+ def _bytes_match_extension(data: bytes, ext: str) -> bool:
861
+ return _bytes_match_extension_impl(data, ext)
862
+
863
+ _LOCAL_APPROVAL_TTL_SECONDS = 5 * 60
864
+ _local_approvals: Dict[str, Dict[str, object]] = {}
865
+
866
+
867
+ def _normalize_local_path_for_approval(path: str) -> str:
868
+ return str(Path(path).expanduser().resolve())
869
+
870
+
871
+ def _content_fingerprint(content: str = "") -> str:
872
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
873
+
874
+
875
+ def _local_permission_response(path: str, action: str, user_email: str, content: str = "") -> dict:
876
+ normalized = _normalize_local_path_for_approval(path)
877
+ token = secrets.token_urlsafe(24)
878
+ record: Dict[str, object] = {
879
+ "path": normalized,
880
+ "action": action,
881
+ "user_email": user_email,
882
+ "expires_at": time.time() + _LOCAL_APPROVAL_TTL_SECONDS,
883
+ "approved": False,
884
+ }
885
+ if action == "write":
886
+ record["content_hash"] = _content_fingerprint(content)
887
+ _local_approvals[token] = record
888
+ return {
889
+ "permission_required": True,
890
+ "path": path,
891
+ "action": action,
892
+ "approval_token": token,
893
+ "expires_in": _LOCAL_APPROVAL_TTL_SECONDS,
894
+ }
895
+
896
+
897
+ def _require_local_approval(
898
+ *,
899
+ token: Optional[str],
900
+ path: str,
901
+ action: str,
902
+ user_email: str,
903
+ content: str = "",
904
+ ) -> None:
905
+ if not token:
906
+ raise HTTPException(status_code=403, detail="파일 접근 승인 토큰이 필요합니다.")
907
+ record = _local_approvals.get(token)
908
+ if not record or float(record.get("expires_at", 0)) < time.time():
909
+ raise HTTPException(status_code=403, detail="파일 접근 승인이 만료되었거나 유효하지 않습니다.")
910
+ if not record.get("approved"):
911
+ raise HTTPException(status_code=403, detail="파일 접근이 아직 승인되지 않았습니다.")
912
+ if record.get("user_email") != user_email:
913
+ raise HTTPException(status_code=403, detail="다른 사용자의 파일 접근 승인은 사용할 수 없습니다.")
914
+ if record.get("path") != _normalize_local_path_for_approval(path) or record.get("action") != action:
915
+ raise HTTPException(status_code=403, detail="파일 접근 승인 범위가 일치하지 않습니다.")
916
+ if action == "write" and record.get("content_hash") != _content_fingerprint(content):
917
+ raise HTTPException(status_code=403, detail="승인된 파일 내용과 요청 내용이 다릅니다.")
918
+
919
+
920
+ def require_admin(request: Request) -> tuple[str, Dict]:
921
+ users = load_users()
922
+ if not REQUIRE_AUTH:
923
+ return "", users
924
+ token = _extract_bearer_token(request)
925
+ if token:
926
+ email = get_session_email(token)
927
+ if email:
928
+ role = get_user_role(email, users)
929
+ try:
930
+ require_capability(role, "admin:users")
931
+ return email, users
932
+ except PermissionError:
933
+ pass
934
+ raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
935
+
936
+ def public_user(email: str, user: Dict, users: Dict) -> Dict:
937
+ role = get_user_role(email, users)
938
+ user_id = user.get("id") or _user_id_for_email(users, email)
939
+ return {
940
+ "id": user_id,
941
+ "email": email,
942
+ "identity": user_id,
943
+ "name": user.get("name", ""),
944
+ "nickname": user.get("nickname", ""),
945
+ "role": role,
946
+ "disabled": bool(user.get("disabled", False)),
947
+ }
948
+
949
+ def get_history_user(email: Optional[str], nickname: Optional[str] = None) -> Dict:
950
+ if not email:
951
+ return {"user_email": None, "user_nickname": nickname or None}
952
+ users = load_users()
953
+ user = users.get(email, {})
954
+ return {
955
+ "user_email": email,
956
+ "user_nickname": nickname or user.get("nickname") or user.get("name") or email,
957
+ }
958
+
959
+ def get_user_api_key(email: Optional[str], provider: str) -> Optional[str]:
960
+ if not email:
961
+ return None
962
+ keyring_key = f"{email}:{provider}"
963
+ if keyring is not None:
964
+ try:
965
+ key = keyring.get_password("LatticeAI", keyring_key)
966
+ if key:
967
+ return key.strip()
968
+ except Exception as exc:
969
+ logging.warning("keyring read failed for %s: %s", provider, exc)
970
+ users = load_users()
971
+ user = users.get(email) or {}
972
+ api_keys = user.get("api_keys") or {}
973
+ key = api_keys.get(provider)
974
+ if isinstance(key, str) and key.strip() and ALLOW_PLAINTEXT_API_KEYS:
975
+ return key.strip()
976
+ return None
977
+
978
+ def set_user_api_key(email: str, provider: str, key: str) -> None:
979
+ keyring_key = f"{email}:{provider}"
980
+ if keyring is not None:
981
+ try:
982
+ keyring.set_password("LatticeAI", keyring_key, key)
983
+ users = load_users()
984
+ user = users.get(email)
985
+ if user and "api_keys" in user:
986
+ user["api_keys"].pop(provider, None)
987
+ if not user["api_keys"]:
988
+ user.pop("api_keys", None)
989
+ save_users(users)
990
+ return
991
+ except Exception as exc:
992
+ logging.warning("keyring write failed for %s: %s", provider, exc)
993
+ if not ALLOW_PLAINTEXT_API_KEYS:
994
+ raise HTTPException(
995
+ status_code=500,
996
+ detail="OS keyring에 API 키를 저장하지 못했습니다. keyring 설정을 확인하거나 LATTICEAI_ALLOW_PLAINTEXT_API_KEYS=true를 명시적으로 설정하세요.",
997
+ )
998
+
999
+ if not ALLOW_PLAINTEXT_API_KEYS:
1000
+ raise HTTPException(
1001
+ status_code=500,
1002
+ detail="keyring 패키지를 사용할 수 없어 API 키를 안전하게 저장할 수 없습니다.",
1003
+ )
1004
+
1005
+ users = load_users()
1006
+ user = users.get(email)
1007
+ if not user:
1008
+ user = {
1009
+ "password_hash": "",
1010
+ "salt": "",
1011
+ "name": email,
1012
+ "nickname": email,
1013
+ "role": "user",
1014
+ "disabled": False,
1015
+ }
1016
+ ensure_user_identity(email, user)
1017
+ api_keys = user.get("api_keys") or {}
1018
+ api_keys[provider] = key
1019
+ user["api_keys"] = api_keys
1020
+ users[email] = user
1021
+ save_users(users)
1022
+
1023
+ # ── Sensitivity analysis — delegated to latticeai.core.audit ──────────────────
1024
+ def classify_sensitive_message(item: Dict, index: int) -> Dict:
1025
+ return _classify_sensitive_message(item, index)
1026
+
1027
+ def build_sensitivity_report(history: List[Dict]) -> Dict:
1028
+ return _build_sensitivity_report(history)
1029
+
1030
+ # ── Admin audit report — delegated to latticeai.core.audit ───────────────────
1031
+ def build_admin_audit_report(users: Dict) -> Dict:
1032
+ graph_stats = None
1033
+ try:
1034
+ if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
1035
+ graph_stats = KNOWLEDGE_GRAPH.stats()
1036
+ except Exception:
1037
+ pass
1038
+ return _build_admin_audit_report(
1039
+ AUDIT_FILE, users,
1040
+ get_user_role=get_user_role,
1041
+ graph_stats=graph_stats,
1042
+ )
1043
+
1044
+ router = LLMRouter()
1045
+ set_llm_router(router)
1046
+ configure_tool_dispatch(load_users=load_users, get_user_role=get_user_role)
1047
+ # v4 garden absorption: the vault is the user-owned markdown mirror; the
1048
+ # brain is authoritative. Existing notes import idempotently at startup
1049
+ # (content-hash dedup — re-runs are no-ops), and garden context queries
1050
+ # the brain instead of rescanning the vault per chat message.
1051
+ gardener = PReinforceGardener(
1052
+ ingestion_pipeline=INGESTION_PIPELINE if ENABLE_GRAPH else None,
1053
+ knowledge_graph=KNOWLEDGE_GRAPH,
1054
+ )
1055
+ if ENABLE_GRAPH:
1056
+ try:
1057
+ _garden_import = gardener.import_vault()
1058
+ if _garden_import.get("failed"):
1059
+ logging.warning("garden vault import: %s notes failed to ingest", _garden_import["failed"])
1060
+ except Exception as exc:
1061
+ logging.warning("garden vault import skipped: %s", exc)
1062
+
1063
+ async def autoload_default_model() -> None:
1064
+ if not AUTOLOAD_MODELS:
1065
+ print("⏭️ Model autoload disabled by LATTICEAI_AUTOLOAD_MODELS=false.")
1066
+ return
1067
+
1068
+ if IS_PUBLIC_MODE:
1069
+ model_id = PUBLIC_MODEL
1070
+ provider = model_id.split(":", 1)[0] if ":" in model_id else "openai"
1071
+ env_by_provider = {
1072
+ "openai": "OPENAI_API_KEY",
1073
+ "openrouter": "OPENROUTER_API_KEY",
1074
+ "groq": "GROQ_API_KEY",
1075
+ "together": "TOGETHER_API_KEY",
1076
+ "ollama": "OLLAMA_API_KEY",
1077
+ }
1078
+ required_env = env_by_provider.get(provider)
1079
+ if required_env and not os.getenv(required_env) and provider != "ollama":
1080
+ print(f"🌐 Public mode ready. Set {required_env} to autoload {model_id}.")
1081
+ return
1082
+ print(f"🌐 Public mode autoload: {model_id}")
1083
+ try:
1084
+ msg = await router.load_model(model_id)
1085
+ print(f"✅ {msg}")
1086
+ except Exception as e:
1087
+ print(f"⚠️ Public model autoload failed: {e}")
1088
+ return
1089
+
1090
+ if not ALLOW_LOCAL_MODELS:
1091
+ print("⏭️ Local model autoload skipped because LATTICEAI_ALLOW_LOCAL_MODELS=false.")
1092
+ return
1093
+
1094
+ print("⏳ Auto-loading local model stack:")
1095
+ print(f" - Target: {LOCAL_MODEL}")
1096
+ if LOCAL_DRAFT_MODEL:
1097
+ print(f" - Draft: {LOCAL_DRAFT_MODEL}")
1098
+ else:
1099
+ print(" - Draft: disabled (set LATTICEAI_LOCAL_DRAFT_MODEL to enable)")
1100
+ try:
1101
+ await router.load_model(LOCAL_MODEL, draft_model_id=LOCAL_DRAFT_MODEL or None)
1102
+ except Exception as e:
1103
+ print(f"⚠️ Local model autoload failed: {e}")
1104
+
1105
+ async def unload_idle_models_loop() -> None:
1106
+ if MODEL_IDLE_UNLOAD_SECONDS <= 0:
1107
+ print("⏭️ Model idle unload disabled.")
1108
+ return
1109
+ while True:
1110
+ await asyncio.sleep(min(60, MODEL_IDLE_UNLOAD_SECONDS))
1111
+ try:
1112
+ unloaded = router.unload_idle_models(MODEL_IDLE_UNLOAD_SECONDS)
1113
+ if unloaded:
1114
+ print(f"🧹 Idle model unload: {', '.join(unloaded)}")
1115
+ except Exception as e:
1116
+ logging.warning("Idle model unload failed: %s", e)
1117
+
1118
+ def _spawn(coro, *, name: str):
1119
+ """Fire-and-forget asyncio task that logs exceptions instead of swallowing them."""
1120
+ task = asyncio.create_task(coro, name=name)
1121
+ def _on_done(t: asyncio.Task) -> None:
1122
+ if t.cancelled():
1123
+ return
1124
+ exc = t.exception()
1125
+ if exc is not None:
1126
+ logging.warning("background task '%s' failed: %s", name, exc)
1127
+ task.add_done_callback(_on_done)
1128
+ return task
1129
+
1130
+
1131
+ @asynccontextmanager
1132
+ async def lifespan(app: FastAPI):
1133
+ try:
1134
+ print(f"🧭 Lattice AI mode: {APP_MODE}")
1135
+ if ENABLE_TELEGRAM:
1136
+ from telegram_bot import run_bot
1137
+ _spawn(run_bot(), name="telegram_bot")
1138
+ print("🚀 Telegram Bot Bridge activated!")
1139
+ else:
1140
+ print("⏭️ Telegram Bot Bridge disabled for this mode.")
1141
+ _spawn(unload_idle_models_loop(), name="unload_idle_models")
1142
+ _spawn(autoload_default_model(), name="autoload_default_model")
1143
+ if LOCAL_KG_WATCHER:
1144
+ restored = LOCAL_KG_WATCHER.restore_enabled_sources()
1145
+ if restored.get("restored"):
1146
+ print(f"🕸️ Local knowledge watchers restored: {restored['restored']}")
1147
+ except Exception as e:
1148
+ print(f"⚠️ Startup sequence failed: {e}")
1149
+ try:
1150
+ yield
1151
+ finally:
1152
+ if LOCAL_KG_WATCHER:
1153
+ LOCAL_KG_WATCHER.stop_all()
1154
+ router.unload_all()
1155
+ for proc in LOCAL_SERVER_PROCESSES.values():
1156
+ try:
1157
+ if proc.poll() is None:
1158
+ proc.terminate()
1159
+ proc.wait(timeout=5)
1160
+ except Exception:
1161
+ pass
1162
+
1163
+ app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version=APP_VERSION, lifespan=lifespan)
1164
+
1165
+ CORS_ALLOWED_ORIGINS = [
1166
+ f"http://localhost:{DEFAULT_PORT}",
1167
+ f"http://127.0.0.1:{DEFAULT_PORT}",
1168
+ *CORS_EXTRA_ORIGINS,
1169
+ ]
1170
+ if CORS_ALLOW_NETWORK:
1171
+ CORS_ALLOWED_ORIGINS = CORS_ALLOWED_ORIGINS + [
1172
+ f"http://{DEFAULT_HOST}:{DEFAULT_PORT}",
1173
+ f"https://{DEFAULT_HOST}:{DEFAULT_PORT}",
1174
+ ]
1175
+
1176
+ app.add_middleware(
1177
+ CORSMiddleware,
1178
+ allow_origins=CORS_ALLOWED_ORIGINS,
1179
+ allow_methods=["*"],
1180
+ allow_headers=["*"],
1181
+ allow_credentials=True,
1182
+ )
1183
+
1184
+ # UI 파일이 담길 static 폴더 연결
1185
+ STATIC_DIR.mkdir(parents=True, exist_ok=True)
1186
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
1187
+ # PWA icons served at /icons/*
1188
+ _ICONS_DIR = STATIC_DIR / "icons"
1189
+ if _ICONS_DIR.exists():
1190
+ app.mount("/icons", StaticFiles(directory=str(_ICONS_DIR)), name="icons")
1191
+ ensure_agent_root()
1192
+
1193
+ OPEN_REGISTRATION = CONFIG.open_registration
1194
+ INVITE_CODE = CONFIG.invite_code
1195
+ INVITE_GATE_ENABLED = CONFIG.invite_gate_enabled
1196
+ configure_model_runtime(
1197
+ router=router,
1198
+ APP_MODE=APP_MODE,
1199
+ DEFAULT_HOST=DEFAULT_HOST,
1200
+ DEFAULT_PORT=DEFAULT_PORT,
1201
+ DATA_DIR=DATA_DIR,
1202
+ BASE_DIR=BASE_DIR,
1203
+ ENABLE_TELEGRAM=ENABLE_TELEGRAM,
1204
+ ENABLE_GRAPH=ENABLE_GRAPH,
1205
+ AUTOLOAD_MODELS=AUTOLOAD_MODELS,
1206
+ MODEL_IDLE_UNLOAD_SECONDS=MODEL_IDLE_UNLOAD_SECONDS,
1207
+ ALLOW_LOCAL_MODELS=ALLOW_LOCAL_MODELS,
1208
+ REQUIRE_AUTH=REQUIRE_AUTH,
1209
+ INVITE_GATE_ENABLED=INVITE_GATE_ENABLED,
1210
+ ALLOW_PLAINTEXT_API_KEYS=ALLOW_PLAINTEXT_API_KEYS,
1211
+ CORS_ALLOW_NETWORK=CORS_ALLOW_NETWORK,
1212
+ PUBLIC_MODEL=PUBLIC_MODEL,
1213
+ LOCAL_MODEL=LOCAL_MODEL,
1214
+ IS_PUBLIC_MODE=IS_PUBLIC_MODE,
1215
+ keyring=keyring,
1216
+ get_current_user=get_current_user,
1217
+ get_user_api_key=get_user_api_key,
1218
+ )
1219
+ STATIC_ROUTES = create_static_routes_router(
1220
+ static_dir=STATIC_DIR,
1221
+ invite_gate_enabled=INVITE_GATE_ENABLED,
1222
+ invite_code=INVITE_CODE,
1223
+ app_mode=APP_MODE,
1224
+ model_router=router,
1225
+ require_user=require_user,
1226
+ )
1227
+ ui_file_response = STATIC_ROUTES.ui_file_response
1228
+ local_sysinfo = STATIC_ROUTES.local_sysinfo
1229
+ app.include_router(STATIC_ROUTES.router)
1230
+
1231
+ # ── Auth & Admin routers (latticeai.api) ─────────────────────────────────────
1232
+ app.include_router(create_auth_router(
1233
+ load_users=load_users, save_users=save_users,
1234
+ hash_password=hash_password, verify_and_migrate=verify_and_migrate_password,
1235
+ create_session=create_session, get_session_email=get_session_email,
1236
+ invalidate_session=invalidate_session, extract_bearer_token=_extract_bearer_token,
1237
+ get_user_role=get_user_role, require_user=require_user,
1238
+ check_ip_rate_limit=_check_rate_limit, client_ip=_client_ip,
1239
+ get_sso_settings=get_sso_settings, get_sso_discovery=_get_sso_discovery,
1240
+ public_sso_config=public_sso_config,
1241
+ open_registration=OPEN_REGISTRATION, session_ttl=_SESSION_TTL,
1242
+ require_auth=REQUIRE_AUTH,
1243
+ ensure_identity=ensure_user_identity,
1244
+ ))
1245
+
1246
+ def _graph_stats_safe():
1247
+ try:
1248
+ return KNOWLEDGE_GRAPH.stats() if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else {"disabled": True}
1249
+ except Exception as e:
1250
+ return {"error": str(e)}
1251
+
1252
+ app.include_router(create_admin_router(
1253
+ require_admin=require_admin, require_user=require_user,
1254
+ load_users=load_users, save_users=save_users,
1255
+ get_user_role=get_user_role, get_history=get_history,
1256
+ public_user=public_user, load_vpc_config=load_vpc_config,
1257
+ save_vpc_config=save_vpc_config,
1258
+ build_admin_audit_report=build_admin_audit_report,
1259
+ build_sensitivity_report=build_sensitivity_report,
1260
+ append_audit_event=append_audit_event,
1261
+ public_sso_config=public_sso_config, save_sso_config=save_sso_config,
1262
+ get_graph_stats=_graph_stats_safe, enable_graph=ENABLE_GRAPH,
1263
+ invite_code=INVITE_CODE, invite_gate_enabled=INVITE_GATE_ENABLED,
1264
+ default_port=DEFAULT_PORT,
1265
+ policy_matrix=policy_matrix,
1266
+ ))
1267
+
1268
+ app.include_router(create_invitations_router(
1269
+ invitation_store=INVITATION_STORE,
1270
+ workspace_service=WORKSPACE_SERVICE,
1271
+ require_admin=require_admin,
1272
+ require_user=require_user,
1273
+ user_id_for_email=user_id_for_email,
1274
+ append_audit_event=append_audit_event,
1275
+ ))
1276
+
1277
+ # ── Security & Audit Command Center (피드백 #5) ──────────────────────────────
1278
+ def _security_audit_events_safe() -> List[Dict]:
1279
+ try:
1280
+ return _get_audit_log(AUDIT_FILE)
1281
+ except Exception as e:
1282
+ logging.warning("security audit events load failed: %s", e)
1283
+ return []
1284
+
1285
+ def _security_list_uploaded_files() -> List[Dict]:
1286
+ """Audit log에서 document_upload 이벤트를 가공해서 file 목록으로 노출."""
1287
+ files: List[Dict] = []
1288
+ for idx, e in enumerate(_security_audit_events_safe()):
1289
+ if e.get("event_type") != "document_upload":
1290
+ continue
1291
+ files.append({
1292
+ "file_id": str(e.get("filename") or idx),
1293
+ "filename": e.get("filename"),
1294
+ "user_email": e.get("user_email"),
1295
+ "user_nickname": e.get("user_nickname"),
1296
+ "uploaded_at": e.get("timestamp"),
1297
+ "ext": e.get("ext"),
1298
+ "bytes": e.get("bytes"),
1299
+ "sensitivity": e.get("sensitivity") or "none",
1300
+ "sensitive_labels": e.get("sensitive_labels") or [],
1301
+ "content_preview": e.get("content_preview"),
1302
+ })
1303
+ return files
1304
+
1305
+ app.include_router(_create_security_router(
1306
+ require_admin=require_admin,
1307
+ get_history=get_history,
1308
+ get_audit_events=_security_audit_events_safe,
1309
+ classify_sensitive_message=classify_sensitive_message,
1310
+ build_sensitivity_report=build_sensitivity_report,
1311
+ list_uploaded_files=_security_list_uploaded_files,
1312
+ append_audit_event=append_audit_event,
1313
+ ))
1314
+
1315
+ # ── Static UI/status routes moved to latticeai.api.static_routes ──
1316
+
1317
+ # ── Request / Response Models ──────────────────────────────────────────────────
1318
+
1319
+ # ── Workspace OS API ──────────────────────────────────────────────────────────
1320
+
1321
+ def _workspace_settings_payload() -> Dict:
1322
+ return {
1323
+ "mode": APP_MODE,
1324
+ "host": DEFAULT_HOST,
1325
+ "port": DEFAULT_PORT,
1326
+ "require_auth": REQUIRE_AUTH,
1327
+ "enable_graph": ENABLE_GRAPH,
1328
+ "allow_local_models": ALLOW_LOCAL_MODELS,
1329
+ "static_dir": str(STATIC_DIR),
1330
+ "data_dir": str(DATA_DIR),
1331
+ }
1332
+
1333
+
1334
+ def _workspace_models_payload() -> Dict:
1335
+ return {
1336
+ "current_model": router.current_model_id,
1337
+ "loaded_models": router.loaded_model_ids,
1338
+ "public_model": PUBLIC_MODEL,
1339
+ "local_model": LOCAL_MODEL,
1340
+ "local_draft_model": LOCAL_DRAFT_MODEL,
1341
+ }
1342
+
1343
+
1344
+ def _workspace_graph():
1345
+ return KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None
1346
+
1347
+
1348
+ SEARCH_SERVICE = SearchService(graph_store=_workspace_graph())
1349
+
1350
+ # ── v4 Context System: one budgeted, provenance-carrying assembly over the
1351
+ # product's own retrieval stack (memories + hybrid search + garden notes).
1352
+ BRAIN_MEMORY = BrainMemory(INGESTION_PIPELINE)
1353
+ def _scoped_hybrid_search(q, user_email=None, **kw):
1354
+ allowed = None
1355
+ if REQUIRE_AUTH and user_email:
1356
+ allowed = PLATFORM.allowed_scopes(user_email)
1357
+ return SEARCH_SERVICE.hybrid_search(q, allowed_workspaces=allowed, **kw)
1358
+
1359
+ CONTEXT_ASSEMBLER = ContextAssembler(
1360
+ memory_recall=MEMORY_SERVICE.recall,
1361
+ hybrid_search=_scoped_hybrid_search,
1362
+ notes_context=gardener.get_relevant_context,
1363
+ )
1364
+
1365
+
1366
+ # ── Telegram chat mirror: registered only when ENABLE_TELEGRAM is truthy.
1367
+ # latticeai.api.chat no longer imports telegram_bot (a 45KB module that
1368
+ # mutates os.environ at import); it calls this injected callback instead.
1369
+ on_chat_message = None
1370
+ if ENABLE_TELEGRAM:
1371
+ def _telegram_chat_mirror(role: str, text: str, source: Optional[str] = None) -> None:
1372
+ from telegram_bot import broadcast_web_chat
1373
+ _spawn(broadcast_web_chat(role, text), name="telegram_broadcast")
1374
+ on_chat_message = _telegram_chat_mirror
1375
+
1376
+ # ── Typed dependency context (latticeai.services.app_context) ────────────────
1377
+ # One context object replaces the historical 25-30-kwarg router wiring.
1378
+ context = AppContext(
1379
+ config=CONFIG,
1380
+ data_dir=DATA_DIR,
1381
+ static_dir=STATIC_DIR,
1382
+ base_dir=BASE_DIR,
1383
+ skills_dir=SKILLS_DIR,
1384
+ model_router=router,
1385
+ workspace_store=WORKSPACE_OS,
1386
+ workspace_service=WORKSPACE_SERVICE,
1387
+ knowledge_graph=KNOWLEDGE_GRAPH,
1388
+ local_kg_watcher=LOCAL_KG_WATCHER,
1389
+ chat_service=CHAT_SERVICE,
1390
+ context_assembler=CONTEXT_ASSEMBLER,
1391
+ brain_memory=BRAIN_MEMORY,
1392
+ gardener=gardener,
1393
+ hooks=HOOKS_REGISTRY,
1394
+ realtime_bus=REALTIME_BUS,
1395
+ capability_registry=capability_registry,
1396
+ require_user=require_user,
1397
+ require_admin=require_admin,
1398
+ get_current_user=get_current_user,
1399
+ load_users=load_users,
1400
+ get_user_role=get_user_role,
1401
+ enforce_rate_limit=enforce_rate_limit,
1402
+ append_audit_event=append_audit_event,
1403
+ get_audit_log=get_audit_log,
1404
+ get_history=get_history,
1405
+ get_history_user=get_history_user,
1406
+ save_to_history=save_to_history,
1407
+ clear_history=clear_history,
1408
+ clear_conversation=clear_conversation,
1409
+ group_history_conversations=group_history_conversations,
1410
+ get_conversation_messages=get_conversation_messages,
1411
+ conversation_title=conversation_title,
1412
+ enable_graph=ENABLE_GRAPH,
1413
+ require_graph=_require_graph,
1414
+ workspace_graph=_workspace_graph,
1415
+ graph_stats=_graph_stats_safe,
1416
+ workspace_models=_workspace_models_payload,
1417
+ workspace_settings=_workspace_settings_payload,
1418
+ scan_environment=scan_environment,
1419
+ local_sysinfo=local_sysinfo,
1420
+ get_recommendations=get_recommendations,
1421
+ fetch_skills_marketplace=_fetch_skills_marketplace,
1422
+ install_skill=install_skill,
1423
+ remove_skill_directory=remove_skill_directory,
1424
+ redact_secret_text=redact_secret_text,
1425
+ ui_file_response=ui_file_response,
1426
+ public_model=PUBLIC_MODEL,
1427
+ local_model=LOCAL_MODEL or "",
1428
+ on_chat_message=on_chat_message,
1429
+ )
1430
+ app.state.context = context
1431
+
1432
+ # ── Workspace OS + Organization router (latticeai.api.workspace, v1.2.0) ──────
1433
+ app.include_router(create_workspace_router(context))
1434
+
1435
+
1436
+ # ── v2 Agentic Workspace Platform: cross-system wiring ───────────────────────
1437
+ # All cross-subsystem closures live in latticeai.services.platform_runtime to
1438
+ # keep this assembly file lean; server_app only constructs it and mounts routers.
1439
+ def _llm_generate_sync(message: str, context: str = "", max_tokens: int = 1024, temperature: float = 0.1) -> str:
1440
+ # Synchronous model bridge for the orchestrator's role runner. Safe
1441
+ # because the agents run endpoint executes start() in a worker thread
1442
+ # (asyncio.to_thread), where no event loop is running.
1443
+ import asyncio as _asyncio
1444
+
1445
+ return str(_asyncio.run(router.generate(
1446
+ message, context=context, max_tokens=max_tokens, temperature=temperature,
1447
+ )))
1448
+
1449
+ PLATFORM = PlatformRuntime(
1450
+ store=WORKSPACE_OS,
1451
+ workspace_service=WORKSPACE_SERVICE,
1452
+ plugin_registry=PLUGIN_REGISTRY,
1453
+ get_current_user=get_current_user,
1454
+ workspace_graph=_workspace_graph,
1455
+ workspace_scope_from_request=_workspace_scope_from_request,
1456
+ get_tool_permission=get_tool_permission,
1457
+ hooks=HOOKS_REGISTRY,
1458
+ llm_generate=_llm_generate_sync,
1459
+ llm_available=lambda: bool(getattr(router, "current_model_id", None)),
1460
+ agent_registry=AGENT_REGISTRY,
1461
+ )
1462
+
1463
+ # ── v4 Trigger system (T7d): interval + brain-event workflow triggers.
1464
+ from latticeai.services.triggers import TRIGGER_HOOK_NAME, TriggerService
1465
+
1466
+ TRIGGER_SERVICE = TriggerService(
1467
+ store=WORKSPACE_OS,
1468
+ run_workflow=lambda wf_id, inputs: PLATFORM.run_workflow_by_id(
1469
+ wf_id, None, None, with_agent=False, inputs=inputs,
1470
+ ),
1471
+ data_dir=DATA_DIR,
1472
+ )
1473
+ # Idempotent hook registration: ingestion post_tool events fan into triggers.
1474
+ _trigger_hook_id = next(
1475
+ (h.get("id") for h in HOOKS_REGISTRY._state.get("custom", [])
1476
+ if h.get("name") == TRIGGER_HOOK_NAME),
1477
+ None,
1478
+ )
1479
+ if _trigger_hook_id is None:
1480
+ _trigger_hook_id = HOOKS_REGISTRY.register(
1481
+ name=TRIGGER_HOOK_NAME,
1482
+ kind="post_tool",
1483
+ description="Fires brain_event workflow triggers when knowledge enters the brain.",
1484
+ )["id"]
1485
+ HOOKS_REGISTRY.register_hook(_trigger_hook_id, TRIGGER_SERVICE.hook_runner())
1486
+
1487
+ # Single AgentRuntime boundary over the orchestrator + run store.
1488
+ AGENT_RUNTIME = AgentRuntime(
1489
+ store=WORKSPACE_OS,
1490
+ orchestrator_factory=PLATFORM.build_orchestrator,
1491
+ workspace_graph=_workspace_graph,
1492
+ append_audit_event=append_audit_event,
1493
+ hooks=HOOKS_REGISTRY,
1494
+ )
1495
+ RUN_EXECUTOR = RunExecutor(
1496
+ store=WORKSPACE_OS,
1497
+ agent_runtime=AGENT_RUNTIME,
1498
+ build_workflow_runners=PLATFORM.build_workflow_runners,
1499
+ workspace_graph=_workspace_graph,
1500
+ append_audit_event=append_audit_event,
1501
+ hooks=HOOKS_REGISTRY,
1502
+ )
1503
+ AGENT_RUNTIME.attach_executor(RUN_EXECUTOR)
1504
+ app.state.run_executor = RUN_EXECUTOR
1505
+ app.state.run_reconciliation = RUN_EXECUTOR.reconcile_startup()
1506
+ TRIGGER_SERVICE.start()
1507
+
1508
+ # ── Hooks dispatch: bind real built-in runners ───────────────────────────────
1509
+ # The registry lists built-in hooks; binding a runner here makes them *execute*
1510
+ # real platform behaviour when fired (not a placeholder). Runners take a
1511
+ # HookContext and may mutate its payload, return a status dict, or block.
1512
+ # Bind a real runner to every built-in hook so none is a silent no-op.
1513
+ register_builtin_hook_runners(
1514
+ HOOKS_REGISTRY,
1515
+ append_audit_event=append_audit_event,
1516
+ get_tool_permission=get_tool_permission,
1517
+ classify_sensitive_message=classify_sensitive_message,
1518
+ )
1519
+
1520
+ app.include_router(create_plugins_router(
1521
+ registry=PLUGIN_REGISTRY,
1522
+ require_user=require_user,
1523
+ require_admin=require_admin,
1524
+ append_audit_event=append_audit_event,
1525
+ register_skill=PLATFORM.register_plugin_skill,
1526
+ plugin_runners_factory=lambda: PLATFORM.plugin_capability_runners(None, None),
1527
+ ui_file_response=ui_file_response,
1528
+ static_dir=STATIC_DIR,
1529
+ ))
1530
+
1531
+ app.include_router(create_workflow_designer_router(
1532
+ store=WORKSPACE_OS,
1533
+ require_user=require_user,
1534
+ get_current_user=get_current_user,
1535
+ gate_read=PLATFORM.gate_read,
1536
+ gate_write=PLATFORM.gate_write,
1537
+ workspace_graph=_workspace_graph,
1538
+ build_runners=PLATFORM.build_workflow_runners,
1539
+ append_audit_event=append_audit_event,
1540
+ ui_file_response=ui_file_response,
1541
+ static_dir=STATIC_DIR,
1542
+ hooks=HOOKS_REGISTRY,
1543
+ run_executor=RUN_EXECUTOR,
1544
+ trigger_service=TRIGGER_SERVICE,
1545
+ ))
1546
+
1547
+ app.include_router(create_agents_router(
1548
+ store=WORKSPACE_OS,
1549
+ orchestrator_factory=PLATFORM.build_orchestrator,
1550
+ require_user=require_user,
1551
+ get_current_user=get_current_user,
1552
+ gate_read=PLATFORM.gate_read,
1553
+ gate_write=PLATFORM.gate_write,
1554
+ workspace_graph=_workspace_graph,
1555
+ append_audit_event=append_audit_event,
1556
+ ui_file_response=ui_file_response,
1557
+ static_dir=STATIC_DIR,
1558
+ agent_runtime=AGENT_RUNTIME,
1559
+ run_executor=RUN_EXECUTOR,
1560
+ ))
1561
+
1562
+ app.include_router(create_marketplace_router(
1563
+ store=WORKSPACE_OS,
1564
+ catalog=TEMPLATE_CATALOG,
1565
+ require_user=require_user,
1566
+ gate_read=PLATFORM.gate_read,
1567
+ gate_write=PLATFORM.gate_write,
1568
+ workspace_graph=_workspace_graph,
1569
+ ))
1570
+
1571
+ app.include_router(create_realtime_router(
1572
+ bus=REALTIME_BUS,
1573
+ require_user=require_user,
1574
+ get_current_user=get_current_user,
1575
+ allowed_scopes=PLATFORM.allowed_scopes,
1576
+ ui_file_response=ui_file_response,
1577
+ static_dir=STATIC_DIR,
1578
+ ))
1579
+
1580
+
1581
+ # ── Health & Info ──────────────────────────────────────────────────────────────
1582
+
1583
+ # ── Model runtime/provider helpers moved to latticeai.services.model_runtime ──
1584
+ # ── Health / status / engine-summary router (latticeai.api.health, v1.2.0) ───
1585
+ # /health, /mode, /runtime_features, /engines(GET) now live in the health router.
1586
+ # Heavier engine mutation endpoints remain below in server_app.
1587
+ MODEL_SERVICE = ModelService(
1588
+ model_router=router,
1589
+ runtime_features=runtime_features,
1590
+ is_public=IS_PUBLIC_MODE,
1591
+ )
1592
+ app.include_router(create_health_router(
1593
+ model_service=MODEL_SERVICE,
1594
+ engine_status=engine_status,
1595
+ get_current_user=get_current_user,
1596
+ require_auth=REQUIRE_AUTH,
1597
+ app_version=APP_VERSION,
1598
+ app_mode=APP_MODE,
1599
+ ))
1600
+
1601
+
1602
+ # ── Model / Engine router (latticeai.api.models, v1.3.0) ─────────────────────
1603
+ app.include_router(create_models_router(
1604
+ model_router=router,
1605
+ require_user=require_user,
1606
+ get_current_user=get_current_user,
1607
+ load_users=load_users,
1608
+ get_user_role=get_user_role,
1609
+ install_engine=install_engine,
1610
+ verify_cloud_models=verify_cloud_models,
1611
+ normalize_local_model_request=normalize_local_model_request,
1612
+ download_hf_model=download_hf_model,
1613
+ prepare_and_load_model=prepare_and_load_model,
1614
+ prepare_and_load_model_stream=prepare_and_load_model_stream,
1615
+ sse_event=sse_event,
1616
+ ensure_ollama_server=ensure_ollama_server,
1617
+ local_binary=local_binary,
1618
+ engine_status=engine_status,
1619
+ filter_lower_family_versions=filter_lower_family_versions,
1620
+ list_compat_profiles=_list_compat_profiles,
1621
+ set_user_api_key=set_user_api_key,
1622
+ engine_model_catalog=ENGINE_MODEL_CATALOG,
1623
+ model_engine_aliases=MODEL_ENGINE_ALIASES,
1624
+ cloud_verify_ttl_seconds=CLOUD_VERIFY_TTL_SECONDS,
1625
+ is_public_mode=IS_PUBLIC_MODE,
1626
+ allow_local_models=ALLOW_LOCAL_MODELS,
1627
+ require_auth=REQUIRE_AUTH,
1628
+ ))
1629
+
1630
+
1631
+ # ── Chat / Completion ──────────────────────────────────────────────────────────
1632
+
1633
+ app.include_router(create_chat_router(context))
1634
+
1635
+ def _embedding_info() -> dict:
1636
+ from latticeai.core.embedding_providers import PROVIDER_TYPES, embedding_provider_profiles
1637
+ info = EMBEDDER.as_dict()
1638
+ info["available_providers"] = list(PROVIDER_TYPES)
1639
+ info["profile"] = CONFIG.embedding_profile or ""
1640
+ info["profiles"] = embedding_provider_profiles()
1641
+ return info
1642
+
1643
+
1644
+ def _allowed_workspaces_for(user):
1645
+ # No-auth local mode is single-user: no scoping. With auth, scope
1646
+ # reads to the caller's memberships (legacy-global rows stay visible).
1647
+ if not REQUIRE_AUTH or not user:
1648
+ return None
1649
+ return PLATFORM.allowed_scopes(user)
1650
+
1651
+ app.include_router(create_search_router(
1652
+ service=SEARCH_SERVICE,
1653
+ allowed_workspaces_for=_allowed_workspaces_for,
1654
+ require_user=require_user,
1655
+ embedding_info=_embedding_info,
1656
+ ))
1657
+
1658
+ app.include_router(create_tools_router(
1659
+ ingestion_pipeline=INGESTION_PIPELINE,
1660
+ config=CONFIG,
1661
+ data_dir=DATA_DIR,
1662
+ static_dir=STATIC_DIR,
1663
+ model_router=router,
1664
+ require_user=require_user,
1665
+ require_admin=require_admin,
1666
+ get_current_user=get_current_user,
1667
+ clear_history=clear_history,
1668
+ append_audit_event=append_audit_event,
1669
+ enforce_rate_limit=enforce_rate_limit,
1670
+ bytes_match_extension=_bytes_match_extension,
1671
+ classify_sensitive_message=classify_sensitive_message,
1672
+ save_to_history=save_to_history,
1673
+ enable_graph=ENABLE_GRAPH,
1674
+ knowledge_graph=KNOWLEDGE_GRAPH,
1675
+ require_graph=_require_graph,
1676
+ local_kg_watcher=LOCAL_KG_WATCHER,
1677
+ load_mcp_installs=load_mcp_installs,
1678
+ recommend_mcps=recommend_mcps,
1679
+ install_mcp=install_mcp,
1680
+ mcp_public_item=mcp_public_item,
1681
+ hooks=HOOKS_REGISTRY,
1682
+ ))
1683
+
1684
+ app.include_router(create_hooks_router(
1685
+ registry=HOOKS_REGISTRY,
1686
+ require_user=require_user,
1687
+ append_audit_event=append_audit_event,
1688
+ ))
1689
+
1690
+ app.include_router(create_agent_registry_router(
1691
+ registry=AGENT_REGISTRY,
1692
+ require_user=require_user,
1693
+ append_audit_event=append_audit_event,
1694
+ ))
1695
+
1696
+ app.include_router(create_memory_router(
1697
+ service=MEMORY_SERVICE,
1698
+ require_user=require_user,
1699
+ get_current_user=get_current_user,
1700
+ gate_read=PLATFORM.gate_read,
1701
+ gate_write=PLATFORM.gate_write,
1702
+ append_audit_event=append_audit_event,
1703
+ ))
1704
+
1705
+ app.include_router(create_browser_router(
1706
+ pipeline=INGESTION_PIPELINE,
1707
+ require_user=require_user,
1708
+ ))
1709
+
1710
+ app.include_router(create_portability_router(
1711
+ service=KG_PORTABILITY,
1712
+ require_user=require_user,
1713
+ require_admin=require_admin,
1714
+ ))
1715
+
1716
+ BRAIN_NETWORK = BrainNetwork(
1717
+ identity=DEVICE_IDENTITY,
1718
+ portability=KG_PORTABILITY,
1719
+ data_dir=DATA_DIR,
1720
+ )
1721
+ app.include_router(create_network_router(
1722
+ network=BRAIN_NETWORK,
1723
+ identity=DEVICE_IDENTITY,
1724
+ require_user=require_user,
1725
+ ))
1726
+
1727
+ app.include_router(create_garden_router(gardener=gardener, require_user=require_user))
1728
+ app.include_router(create_setup_router(model_router=router, require_user=require_user))
1729
+
1730
+ # ── Entry Point ────────────────────────────────────────────────────────────────
1731
+
1732
+ def main() -> None:
1733
+ print(f"🧠 Lattice AI Server starting in {APP_MODE} mode on http://{DEFAULT_HOST}:{DEFAULT_PORT}")
1734
+ uvicorn.run(app, host=DEFAULT_HOST, port=DEFAULT_PORT, log_level="info")
1735
+
1736
+ # ── Constructed-namespace export (consumed by AppRuntime) ────────────────
1737
+ # Every local — singletons, helper functions, request models — becomes an
1738
+ # attribute of the runtime so the legacy ``server_app`` surface survives.
1739
+ return dict(locals())
1740
+
1741
+
1742
+ class AppRuntime:
1743
+ """The constructed application namespace.
1744
+
1745
+ Exposes every name the legacy import-time ``server_app`` module defined
1746
+ (``app``, ``KNOWLEDGE_GRAPH``, ``load_users``, …) as attributes.
1747
+ """
1748
+
1749
+ def __init__(self, namespace: Dict[str, Any]) -> None:
1750
+ self.__dict__.update(namespace)
1751
+
1752
+
1753
+ _runtime_lock = threading.RLock()
1754
+ _shared_runtime: "Optional[AppRuntime]" = None
1755
+
1756
+
1757
+ def build_runtime(config: "Optional[Config]" = None) -> AppRuntime:
1758
+ """Construct a fresh runtime (all singletons + FastAPI app)."""
1759
+ return AppRuntime(_build(config))
1760
+
1761
+
1762
+ def get_shared_runtime() -> AppRuntime:
1763
+ """The process-wide runtime backing ``latticeai.server_app`` / ``server``.
1764
+
1765
+ Built once, on first access — never at import time.
1766
+ """
1767
+ global _shared_runtime
1768
+ if _shared_runtime is None:
1769
+ with _runtime_lock:
1770
+ if _shared_runtime is None:
1771
+ _shared_runtime = build_runtime()
1772
+ return _shared_runtime
1773
+
1774
+
1775
+ def create_app(config: "Optional[Config]" = None) -> "FastAPI":
1776
+ """Build and return the FastAPI application (the factory entrypoint)."""
1777
+ return build_runtime(config).app
1778
+
1779
+
1780
+ def main() -> None:
1781
+ get_shared_runtime().main()