ltcai 11.2.0 → 11.4.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 (247) hide show
  1. package/README.md +46 -53
  2. package/docs/CHANGELOG.md +61 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +6 -2
  8. package/docs/PERMISSION_MODE.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +2 -2
  12. package/docs/v11.3.0_PLAN.md +202 -0
  13. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/graph/_kg_common/__init__.py +287 -0
  16. package/lattice_brain/graph/_kg_common/extraction.py +516 -0
  17. package/lattice_brain/graph/_kg_common/relations.py +161 -0
  18. package/lattice_brain/graph/_kg_common/text.py +479 -0
  19. package/lattice_brain/graph/discovery_index/__init__.py +35 -0
  20. package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
  21. package/lattice_brain/graph/discovery_index/extract.py +137 -0
  22. package/lattice_brain/graph/discovery_index/scan.py +411 -0
  23. package/lattice_brain/graph/discovery_index/upsert.py +495 -0
  24. package/lattice_brain/graph/projection/__init__.py +42 -0
  25. package/lattice_brain/graph/projection/curation.py +500 -0
  26. package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +15 -477
  27. package/lattice_brain/graph/retrieval/__init__.py +54 -0
  28. package/lattice_brain/graph/retrieval/context.py +197 -0
  29. package/lattice_brain/graph/retrieval/graph_view.py +319 -0
  30. package/lattice_brain/graph/retrieval/hybrid.py +488 -0
  31. package/lattice_brain/graph/retrieval/maintenance.py +121 -0
  32. package/lattice_brain/graph/retrieval/signals.py +95 -0
  33. package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
  34. package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
  35. package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
  36. package/lattice_brain/graph/retrieval_vector/search.py +560 -0
  37. package/lattice_brain/graph/retrieval_vector/status.py +374 -0
  38. package/lattice_brain/ingestion/__init__.py +130 -0
  39. package/lattice_brain/ingestion/_contract.py +90 -0
  40. package/lattice_brain/ingestion/constants.py +127 -0
  41. package/lattice_brain/ingestion/folder_scan.py +57 -0
  42. package/lattice_brain/ingestion/folders.py +258 -0
  43. package/lattice_brain/ingestion/hashing.py +26 -0
  44. package/lattice_brain/ingestion/jobs_api.py +107 -0
  45. package/lattice_brain/ingestion/models.py +80 -0
  46. package/lattice_brain/ingestion/pipeline.py +486 -0
  47. package/lattice_brain/ingestion/quality.py +209 -0
  48. package/lattice_brain/ingestion/routing.py +295 -0
  49. package/lattice_brain/multimodal/__init__.py +164 -0
  50. package/lattice_brain/multimodal/audio.py +77 -0
  51. package/lattice_brain/multimodal/common.py +118 -0
  52. package/lattice_brain/multimodal/images.py +498 -0
  53. package/lattice_brain/multimodal/ports.py +169 -0
  54. package/lattice_brain/multimodal/video.py +410 -0
  55. package/lattice_brain/portability/__init__.py +90 -0
  56. package/lattice_brain/portability/_contract.py +42 -0
  57. package/lattice_brain/portability/backups.py +338 -0
  58. package/lattice_brain/portability/bundles.py +136 -0
  59. package/lattice_brain/portability/constants.py +93 -0
  60. package/lattice_brain/portability/fsops.py +138 -0
  61. package/lattice_brain/portability/service.py +41 -0
  62. package/lattice_brain/{portability.py → portability/sharing.py} +44 -677
  63. package/lattice_brain/runtime/__init__.py +1 -1
  64. package/lattice_brain/runtime/multi_agent.py +1 -1
  65. package/latticeai/__init__.py +1 -1
  66. package/latticeai/api/chronicle.py +63 -0
  67. package/latticeai/core/agent/__init__.py +93 -0
  68. package/latticeai/core/agent/_contract.py +79 -0
  69. package/latticeai/core/agent/context.py +57 -0
  70. package/latticeai/core/agent/deps.py +125 -0
  71. package/latticeai/core/agent/execution.py +622 -0
  72. package/latticeai/core/agent/planning.py +145 -0
  73. package/latticeai/core/agent/recovery.py +157 -0
  74. package/latticeai/core/agent/runtime.py +210 -0
  75. package/latticeai/core/agent/verification.py +231 -0
  76. package/latticeai/core/embedding_providers/__init__.py +151 -0
  77. package/latticeai/core/embedding_providers/base.py +199 -0
  78. package/latticeai/core/embedding_providers/captions.py +162 -0
  79. package/latticeai/core/embedding_providers/profiles.py +126 -0
  80. package/latticeai/core/embedding_providers/text.py +350 -0
  81. package/latticeai/core/embedding_providers/vision.py +352 -0
  82. package/latticeai/core/file_generation/__init__.py +115 -0
  83. package/latticeai/core/file_generation/bundles.py +76 -0
  84. package/latticeai/core/file_generation/extraction.py +154 -0
  85. package/latticeai/core/file_generation/inference.py +235 -0
  86. package/latticeai/core/file_generation/orchestration.py +152 -0
  87. package/latticeai/core/file_generation/prompting.py +117 -0
  88. package/latticeai/core/file_generation/repair.py +114 -0
  89. package/latticeai/core/file_generation/sanitize.py +61 -0
  90. package/latticeai/core/file_generation/validation.py +201 -0
  91. package/latticeai/core/legacy_compatibility.py +1 -1
  92. package/latticeai/core/marketplace.py +1 -1
  93. package/latticeai/core/messages.py +9 -0
  94. package/latticeai/core/workspace_os_constants.py +1 -1
  95. package/latticeai/integrations/telegram_bot/__init__.py +123 -0
  96. package/latticeai/integrations/telegram_bot/__main__.py +17 -0
  97. package/latticeai/integrations/telegram_bot/config.py +86 -0
  98. package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
  99. package/latticeai/integrations/telegram_bot/flows.py +478 -0
  100. package/latticeai/integrations/telegram_bot/helpers.py +322 -0
  101. package/latticeai/integrations/telegram_bot/screens.py +394 -0
  102. package/latticeai/models/router/__init__.py +88 -0
  103. package/latticeai/models/router/_contract.py +66 -0
  104. package/latticeai/models/router/branding.py +56 -0
  105. package/latticeai/models/router/catalog.py +69 -0
  106. package/latticeai/models/router/documents.py +199 -0
  107. package/latticeai/models/router/errors.py +37 -0
  108. package/latticeai/models/router/generation.py +258 -0
  109. package/latticeai/models/router/loading.py +291 -0
  110. package/latticeai/models/router/local_models.py +85 -0
  111. package/latticeai/models/router/registry.py +147 -0
  112. package/latticeai/runtime/build_phases/__init__.py +82 -0
  113. package/latticeai/runtime/build_phases/features.py +407 -0
  114. package/latticeai/runtime/build_phases/foundation.py +555 -0
  115. package/latticeai/runtime/build_phases/web.py +492 -0
  116. package/latticeai/runtime/runtime_context.py +1 -0
  117. package/latticeai/services/architecture_readiness.py +48 -19
  118. package/latticeai/services/brain_intelligence/__init__.py +58 -0
  119. package/latticeai/services/brain_intelligence/_contract.py +71 -0
  120. package/latticeai/services/brain_intelligence/consistency.py +193 -0
  121. package/latticeai/services/brain_intelligence/constants.py +47 -0
  122. package/latticeai/services/brain_intelligence/digest.py +258 -0
  123. package/latticeai/services/brain_intelligence/health.py +331 -0
  124. package/latticeai/services/brain_intelligence/proposals.py +264 -0
  125. package/latticeai/services/brain_intelligence/sampling.py +84 -0
  126. package/latticeai/services/brain_intelligence/service.py +48 -0
  127. package/latticeai/services/chronicle.py +557 -0
  128. package/latticeai/services/memory_service/__init__.py +52 -0
  129. package/latticeai/services/memory_service/_contract.py +100 -0
  130. package/latticeai/services/memory_service/brief.py +431 -0
  131. package/latticeai/services/memory_service/constants.py +57 -0
  132. package/latticeai/services/memory_service/maintenance.py +138 -0
  133. package/latticeai/services/memory_service/manager.py +186 -0
  134. package/latticeai/services/memory_service/proof.py +136 -0
  135. package/latticeai/services/memory_service/recall.py +225 -0
  136. package/latticeai/services/memory_service/service.py +48 -0
  137. package/latticeai/services/memory_service/stores.py +110 -0
  138. package/latticeai/services/model_runtime/__init__.py +322 -0
  139. package/latticeai/services/model_runtime/cloud.py +87 -0
  140. package/latticeai/services/model_runtime/download.py +282 -0
  141. package/latticeai/services/model_runtime/engines.py +341 -0
  142. package/latticeai/services/model_runtime/loading.py +178 -0
  143. package/latticeai/services/model_runtime/service.py +129 -0
  144. package/latticeai/services/model_runtime/state.py +131 -0
  145. package/latticeai/services/model_runtime/status.py +255 -0
  146. package/latticeai/services/product_readiness.py +15 -7
  147. package/latticeai/setup/wizard/__init__.py +126 -0
  148. package/latticeai/setup/wizard/catalog.py +172 -0
  149. package/latticeai/setup/wizard/detect.py +323 -0
  150. package/latticeai/setup/wizard/install.py +348 -0
  151. package/latticeai/setup/wizard/paths.py +168 -0
  152. package/latticeai/setup/wizard/plans.py +74 -0
  153. package/latticeai/setup/wizard/recommend.py +320 -0
  154. package/package.json +6 -2
  155. package/scripts/bump_version.py +14 -0
  156. package/scripts/capture_release_evidence.mjs +33 -21
  157. package/scripts/check_current_release_docs.mjs +1 -1
  158. package/scripts/check_i18n_namespace_coverage.mjs +41 -4
  159. package/scripts/check_max_file_lines.mjs +102 -0
  160. package/scripts/check_release_evidence_bound.mjs +30 -15
  161. package/scripts/check_screenshot_pixel_delta.py +34 -4
  162. package/scripts/check_server_i18n.mjs +1 -0
  163. package/scripts/generate_rust_parity_fixtures.py +562 -0
  164. package/scripts/lib/mock_server_fingerprint.mjs +94 -0
  165. package/scripts/release_screen_claims.json +31 -2
  166. package/src-tauri/Cargo.lock +361 -3
  167. package/src-tauri/Cargo.toml +6 -1
  168. package/src-tauri/src/backend.rs +349 -0
  169. package/src-tauri/src/folder.rs +33 -0
  170. package/src-tauri/src/main.rs +97 -399
  171. package/src-tauri/tauri.conf.json +1 -1
  172. package/static/app/asset-manifest.json +41 -37
  173. package/static/app/assets/Act-yYpYnn0v.js +1 -0
  174. package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
  175. package/static/app/assets/{Brain-tuhI4sOC.js → Brain-C1HBN0Wf.js} +2 -2
  176. package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
  177. package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
  178. package/static/app/assets/Capture-CFIRsFNE.js +1 -0
  179. package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
  180. package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
  181. package/static/app/assets/Library-DwO3yZST.js +1 -0
  182. package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-Jn1GK0-S.js} +1 -1
  183. package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
  184. package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
  185. package/static/app/assets/System-DW8F-2xL.js +1 -0
  186. package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
  187. package/static/app/assets/{bot-Cia42c2h.js → bot-IM_E_Y12.js} +1 -1
  188. package/static/app/assets/brain-Ci1CkWjM.js +1 -0
  189. package/static/app/assets/{button-2j2Ijzgq.js → button-COwyqfHM.js} +1 -1
  190. package/static/app/assets/circle-check-DfInj-qD.js +1 -0
  191. package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-DEM4A1Y5.js} +1 -1
  192. package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-C9djDuLd.js} +1 -1
  193. package/static/app/assets/{cpu-k4awryFq.js → cpu-DFdo1gw-.js} +1 -1
  194. package/static/app/assets/{download-DFbLJ_ig.js → download-SnJL6oqk.js} +1 -1
  195. package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CqZeDkjE.js} +1 -1
  196. package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-j1jJXYYf.js} +1 -1
  197. package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
  198. package/static/app/assets/index-_u5iUHDr.js +10 -0
  199. package/static/app/assets/input-B0lPdRQZ.js +1 -0
  200. package/static/app/assets/link-2-CoFbooHS.js +1 -0
  201. package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-BsyLxtao.js} +1 -1
  202. package/static/app/assets/primitives-DEbN-d6p.js +1 -0
  203. package/static/app/assets/search-BybIWPNd.js +1 -0
  204. package/static/app/assets/{share-2-BH1M-WNi.js → share-2-CVtZ_ewX.js} +1 -1
  205. package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-CBi2GNWM.js} +1 -1
  206. package/static/app/assets/{textarea-CCWbUfFB.js → textarea-DNMpB5ih.js} +1 -1
  207. package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-C83t3GXF.js} +1 -1
  208. package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
  209. package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-Dcp1OChy.js} +1 -1
  210. package/static/app/assets/utils-BlZr7Pd4.js +4 -0
  211. package/static/app/assets/workspace-jJY4RuAV.js +1 -0
  212. package/static/app/index.html +4 -4
  213. package/static/sw.js +1 -1
  214. package/lattice_brain/graph/_kg_common.py +0 -1331
  215. package/lattice_brain/graph/discovery_index.py +0 -1141
  216. package/lattice_brain/graph/retrieval.py +0 -1120
  217. package/lattice_brain/graph/retrieval_vector.py +0 -1293
  218. package/lattice_brain/ingestion.py +0 -1525
  219. package/lattice_brain/multimodal.py +0 -1258
  220. package/latticeai/core/agent.py +0 -1465
  221. package/latticeai/core/embedding_providers.py +0 -1196
  222. package/latticeai/core/file_generation.py +0 -1047
  223. package/latticeai/integrations/telegram_bot.py +0 -1390
  224. package/latticeai/models/router.py +0 -1007
  225. package/latticeai/runtime/build_phases.py +0 -1450
  226. package/latticeai/services/brain_intelligence.py +0 -1083
  227. package/latticeai/services/memory_service.py +0 -1177
  228. package/latticeai/services/model_runtime.py +0 -1281
  229. package/latticeai/setup/wizard.py +0 -1310
  230. package/static/app/assets/Act-AWf0SAKp.js +0 -1
  231. package/static/app/assets/AdminConsole-D0u8Tiyj.js +0 -1
  232. package/static/app/assets/BrainHome-Ts7G_Ila.js +0 -2
  233. package/static/app/assets/BrainSignals-jMYgQ2Ar.js +0 -1
  234. package/static/app/assets/Capture-CqOSzyPr.js +0 -1
  235. package/static/app/assets/CommandPalette-DC0Bzh-I.js +0 -1
  236. package/static/app/assets/Library-CX-bbhmK.js +0 -1
  237. package/static/app/assets/ProductFlow-BHA2cfKI.js +0 -1
  238. package/static/app/assets/ReviewCard-BUhCKRNM.js +0 -3
  239. package/static/app/assets/System-Bu2t5hn1.js +0 -1
  240. package/static/app/assets/arrow-left-Dzwa5zRb.js +0 -1
  241. package/static/app/assets/brain-DJMoqrwx.js +0 -1
  242. package/static/app/assets/index-BpYkzcVm.js +0 -10
  243. package/static/app/assets/input-DSlJJxRs.js +0 -1
  244. package/static/app/assets/primitives-BCx6TvfG.js +0 -1
  245. package/static/app/assets/search-Cgy8cCFJ.js +0 -1
  246. package/static/app/assets/utils-zqPZJxdx.js +0 -4
  247. package/static/app/assets/workspace-DXTihhfU.js +0 -1
@@ -0,0 +1,348 @@
1
+ """Verification, repair, and the SSE install stream.
2
+
3
+ The acting half of the wizard: prove a binary or module is really there, try a
4
+ bounded automatic repair when it is not, and stream one SSE event per step
5
+ while pip/brew/model-load/auth actions run. Every subprocess goes through the
6
+ process-audit command plan, and every plan is confirmed against the token the
7
+ caller returned.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import os
14
+ import platform
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ from typing import Any, AsyncIterator, Dict, List, Tuple
20
+
21
+ from latticeai.core.quiet import quiet
22
+ from latticeai.services.process_audit import (
23
+ CommandConfirmationError,
24
+ append_process_audit_event,
25
+ command_plan,
26
+ require_command_confirmation,
27
+ )
28
+ from latticeai.setup.wizard.paths import (
29
+ _module_available,
30
+ _package_module,
31
+ _which_any,
32
+ repair_path_for,
33
+ )
34
+ from latticeai.setup.wizard.plans import _sse, _verify_action_confirmation
35
+
36
+
37
+ def _verify_binary(binary: str, version_args: List[str] | None = None, timeout: int = 20) -> Tuple[bool, str]:
38
+ repair_path_for(binary)
39
+ found = _which_any(binary)
40
+ if not found:
41
+ return False, f"{binary} 실행 파일을 PATH에서 찾지 못했습니다."
42
+ args = [found, *(version_args or ["--version"])]
43
+ try:
44
+ completed = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
45
+ except Exception as e:
46
+ return False, str(e)
47
+ output = (completed.stdout or completed.stderr or "").strip().splitlines()
48
+ if completed.returncode == 0:
49
+ return True, output[0] if output else found
50
+ return False, (completed.stderr or completed.stdout or f"returncode={completed.returncode}")[-400:]
51
+
52
+
53
+ async def _wait_for_binary(binary: str, seconds: int = 300) -> Tuple[bool, str]:
54
+ deadline = time.time() + seconds
55
+ while time.time() < deadline:
56
+ ok, msg = _verify_binary(binary)
57
+ if ok:
58
+ return True, msg
59
+ await asyncio.sleep(2)
60
+ return False, f"{binary} 설치 완료를 제한 시간 안에 감지하지 못했습니다."
61
+
62
+ # ── Installation Stream ───────────────────────────────────────────────────────
63
+
64
+ def _verify_action(action: Dict[str, Any]) -> Tuple[bool, str]:
65
+ atype = action.get("type")
66
+ if atype == "pip":
67
+ modules = action.get("verify_modules") or [_package_module(pkg) for pkg in action.get("packages", [])]
68
+ missing = [module for module in modules if not _module_available(module)]
69
+ if missing:
70
+ return False, "Python 모듈 감지 실패: " + ", ".join(missing)
71
+ return True, "Python 모듈 import 테스트 통과"
72
+ binary = action.get("binary")
73
+ if binary:
74
+ return _verify_binary(binary)
75
+ return True, "검증 항목 없음"
76
+
77
+
78
+ async def _repair_action(
79
+ action: Dict[str, Any],
80
+ *,
81
+ confirmation_token: str | None = None,
82
+ actor: str | None = None,
83
+ ) -> Tuple[bool, str]:
84
+ binary = action.get("binary")
85
+ if binary:
86
+ repair_path_for(binary)
87
+ ok, msg = _verify_binary(binary)
88
+ if ok:
89
+ return True, f"PATH 자동 보정 완료: {msg}"
90
+ if action.get("type") == "pip":
91
+ packages = action.get("packages", [])
92
+ if packages:
93
+ if not _verify_action_confirmation(action, confirmation_token, name="repair_action"):
94
+ return False, "설치 명령 확인 토큰이 일치하지 않습니다."
95
+ for pkg in packages:
96
+ success, err = await _pip_install(pkg, confirmed=True, actor=actor)
97
+ if not success:
98
+ return False, err
99
+ return _verify_action(action)
100
+ return False, "자동 복구 방법을 찾지 못했습니다."
101
+
102
+
103
+ async def install_stream(
104
+ items: List[Dict],
105
+ router: Any,
106
+ *,
107
+ confirmation_token: str | None = None,
108
+ user_email: str | None = None,
109
+ ) -> AsyncIterator[str]:
110
+ for item in items:
111
+ item_id = item.get("id", "unknown")
112
+ name = item.get("name", item_id)
113
+ action = item.get("action") or {}
114
+ atype = action.get("type")
115
+
116
+ if not atype:
117
+ yield _sse({"id": item_id, "status": "skipped", "msg": f"{name} — 이미 준비됨"})
118
+ await asyncio.sleep(0.04)
119
+ continue
120
+
121
+ yield _sse({"id": item_id, "status": "starting", "msg": f"{name} 준비 중..."})
122
+
123
+ if atype == "pip":
124
+ packages = action.get("packages", [])
125
+ token = confirmation_token or action.get("confirmation_token") or (action.get("command_plan") or {}).get("confirmation_token")
126
+ if not _verify_action_confirmation(action, token, name=str(item_id)):
127
+ yield _sse({"id": item_id, "status": "error", "msg": "설치 명령 확인 토큰이 일치하지 않습니다."})
128
+ continue
129
+ ok = True
130
+ for pkg in packages:
131
+ yield _sse({"id": item_id, "status": "running", "msg": f"pip install {pkg} ..."})
132
+ success, err = await _pip_install(pkg, confirmed=True, actor=user_email)
133
+ if success:
134
+ yield _sse({"id": item_id, "status": "progress", "msg": f"{pkg} 설치 완료"})
135
+ else:
136
+ yield _sse({"id": item_id, "status": "error", "msg": f"{pkg} 실패: {err[:400]}"})
137
+ ok = False
138
+ break
139
+ if ok:
140
+ yield _sse({"id": item_id, "status": "running", "msg": f"{name} 동작 테스트 중..."})
141
+ verified, detail = _verify_action(action)
142
+ if verified:
143
+ yield _sse({"id": item_id, "status": "done", "msg": f"{name} 설치 · 검증 완료 ✅\n{detail}"})
144
+ else:
145
+ yield _sse({"id": item_id, "status": "running", "msg": f"검증 실패 — 자동 복구 중...\n{detail}"})
146
+ repaired, repair_msg = await _repair_action(action, confirmation_token=token, actor=user_email)
147
+ yield _sse({"id": item_id, "status": "done" if repaired else "error", "msg": repair_msg[:500]})
148
+
149
+ elif atype == "brew":
150
+ pkg = action.get("package", "")
151
+ token = confirmation_token or action.get("confirmation_token") or (action.get("command_plan") or {}).get("confirmation_token")
152
+ if not _verify_action_confirmation(action, token, name=str(item_id)):
153
+ yield _sse({"id": item_id, "status": "error", "msg": "설치 명령 확인 토큰이 일치하지 않습니다."})
154
+ continue
155
+ yield _sse({"id": item_id, "status": "running", "msg": f"brew install {pkg} ..."})
156
+ success, err = await _brew_install(pkg, confirmed=True, actor=user_email)
157
+ if success:
158
+ yield _sse({"id": item_id, "status": "running", "msg": "설치 완료 감지 · PATH 보정 중..."})
159
+ binary = action.get("binary")
160
+ if binary:
161
+ repair_path_for(binary)
162
+ verified, detail = _verify_action(action)
163
+ if verified:
164
+ yield _sse({"id": item_id, "status": "done", "msg": f"{name} 설치 · 연결 · 검증 완료 ✅\n{detail}"})
165
+ else:
166
+ yield _sse({"id": item_id, "status": "running", "msg": f"검증 실패 — 자동 복구 중...\n{detail}"})
167
+ repaired, repair_msg = await _repair_action(action, confirmation_token=token, actor=user_email)
168
+ yield _sse({"id": item_id, "status": "done" if repaired else "error", "msg": repair_msg[:500]})
169
+ else:
170
+ url = action.get("official_url") or action.get("url")
171
+ if url:
172
+ yield _sse({"id": item_id, "status": "auth", "msg": f"자동 설치 실패 — 공식 다운로드 페이지를 엽니다.\n{err[:240]}", "auth_url": url})
173
+ open_url(url)
174
+ yield _sse({"id": item_id, "status": "error", "msg": f"실패: {err[:400]}"})
175
+
176
+ elif atype == "load_model":
177
+ model_id = action.get("model_id", "")
178
+ yield _sse({"id": item_id, "status": "running",
179
+ "msg": f"모델 다운로드 · 로딩 중...\n{model_id}\n(용량에 따라 수 분 소요)"})
180
+ try:
181
+ await router.load_model(model_id)
182
+ yield _sse({"id": item_id, "status": "done", "msg": f"{name} 로드 완료 ✅"})
183
+ except Exception as e:
184
+ yield _sse({"id": item_id, "status": "error", "msg": f"로드 실패: {str(e)[:400]}"})
185
+
186
+ elif atype == "auth":
187
+ url = action.get("url", "")
188
+ yield _sse({"id": item_id, "status": "auth",
189
+ "msg": "브라우저에서 인증 페이지를 엽니다...", "auth_url": url})
190
+ open_url(url)
191
+ yield _sse({"id": item_id, "status": "waiting",
192
+ "msg": "브라우저에서 인증 완료 후 계속하세요"})
193
+
194
+ elif atype == "url":
195
+ url = action.get("url", "")
196
+ yield _sse({"id": item_id, "status": "auth",
197
+ "msg": "설치 페이지를 브라우저에서 엽니다...", "auth_url": url})
198
+ open_url(url)
199
+ binary = action.get("binary")
200
+ if binary:
201
+ yield _sse({"id": item_id, "status": "waiting",
202
+ "msg": f"{binary} 설치 완료를 자동 감지하는 중입니다..."})
203
+ ok, detail = await _wait_for_binary(binary)
204
+ if ok:
205
+ repair_path_for(binary)
206
+ yield _sse({"id": item_id, "status": "done",
207
+ "msg": f"{name} 설치 · PATH 연결 · 검증 완료 ✅\n{detail}"})
208
+ else:
209
+ yield _sse({"id": item_id, "status": "error",
210
+ "msg": f"{detail}\n공식 페이지에서 설치 후 다시 시도하세요."})
211
+ else:
212
+ yield _sse({"id": item_id, "status": "waiting",
213
+ "msg": "브라우저에서 설치 또는 인증을 완료한 뒤 다시 시도하세요"})
214
+
215
+ else:
216
+ yield _sse({"id": item_id, "status": "error", "msg": f"알 수 없는 액션: {atype}"})
217
+
218
+ yield _sse({"status": "complete", "msg": "모든 항목 처리 완료!"})
219
+
220
+
221
+ async def _pip_install(
222
+ package: str,
223
+ *,
224
+ confirmation_token: str | None = None,
225
+ confirmed: bool = False,
226
+ actor: str | None = None,
227
+ ) -> Tuple[bool, str]:
228
+ command = [sys.executable, "-m", "pip", "install", "--upgrade", package]
229
+ plan = command_plan(
230
+ command,
231
+ name=f"pip:{package}",
232
+ purpose="setup_wizard_install",
233
+ metadata={"package": package},
234
+ )
235
+ try:
236
+ if not confirmed:
237
+ require_command_confirmation(command, confirmation_token, purpose="setup_wizard_install")
238
+ append_process_audit_event("setup_wizard_install", plan=plan, status="started", user_email=actor)
239
+ proc = await asyncio.create_subprocess_exec(
240
+ *command,
241
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
242
+ )
243
+ _, stderr = await asyncio.wait_for(proc.communicate(), timeout=600)
244
+ stderr_text = stderr.decode(errors="replace")
245
+ append_process_audit_event(
246
+ "setup_wizard_install",
247
+ plan=plan,
248
+ status="finished",
249
+ user_email=actor,
250
+ returncode=proc.returncode,
251
+ stderr=stderr_text,
252
+ )
253
+ if proc.returncode == 0:
254
+ return True, ""
255
+ return False, stderr_text
256
+ except CommandConfirmationError as e:
257
+ append_process_audit_event("setup_wizard_install", plan=plan, status="denied", user_email=actor, error=str(e))
258
+ return False, str(e)
259
+ except asyncio.TimeoutError:
260
+ append_process_audit_event("setup_wizard_install", plan=plan, status="timeout", user_email=actor)
261
+ return False, "설치 시간 초과 (10분)"
262
+ except Exception as e:
263
+ append_process_audit_event("setup_wizard_install", plan=plan, status="error", user_email=actor, error=str(e))
264
+ return False, str(e)
265
+
266
+
267
+ async def _brew_install(
268
+ package: str,
269
+ *,
270
+ confirmation_token: str | None = None,
271
+ confirmed: bool = False,
272
+ actor: str | None = None,
273
+ ) -> Tuple[bool, str]:
274
+ brew = shutil.which("brew")
275
+ if not brew:
276
+ return False, "Homebrew 미설치 — https://brew.sh 에서 설치하세요"
277
+ command = [brew, "install", package]
278
+ plan = command_plan(
279
+ command,
280
+ name=f"brew:{package}",
281
+ purpose="setup_wizard_install",
282
+ metadata={"package": package},
283
+ )
284
+ try:
285
+ if not confirmed:
286
+ require_command_confirmation(command, confirmation_token, purpose="setup_wizard_install")
287
+ append_process_audit_event("setup_wizard_install", plan=plan, status="started", user_email=actor)
288
+ proc = await asyncio.create_subprocess_exec(
289
+ *command,
290
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
291
+ )
292
+ _, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
293
+ stderr_text = stderr.decode(errors="replace")
294
+ append_process_audit_event(
295
+ "setup_wizard_install",
296
+ plan=plan,
297
+ status="finished",
298
+ user_email=actor,
299
+ returncode=proc.returncode,
300
+ stderr=stderr_text,
301
+ )
302
+ if proc.returncode == 0:
303
+ return True, ""
304
+ return False, stderr_text
305
+ except CommandConfirmationError as e:
306
+ append_process_audit_event("setup_wizard_install", plan=plan, status="denied", user_email=actor, error=str(e))
307
+ return False, str(e)
308
+ except asyncio.TimeoutError:
309
+ append_process_audit_event("setup_wizard_install", plan=plan, status="timeout", user_email=actor)
310
+ return False, "설치 시간 초과 (5분)"
311
+ except Exception as e:
312
+ append_process_audit_event("setup_wizard_install", plan=plan, status="error", user_email=actor, error=str(e))
313
+ return False, str(e)
314
+
315
+
316
+ def open_url(url: str) -> None:
317
+ command: List[str]
318
+ try:
319
+ system = platform.system()
320
+ if system == "Darwin":
321
+ command = ["open", url]
322
+ plan = command_plan(command, name="open_url", purpose="setup_wizard_open_url")
323
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="started")
324
+ subprocess.Popen(command)
325
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="spawned")
326
+ elif system == "Windows":
327
+ command = ["os.startfile", url]
328
+ plan = command_plan(command, name="open_url", purpose="setup_wizard_open_url")
329
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="started")
330
+ os.startfile(url) # type: ignore[attr-defined] # noqa: S606 — fixed program, arguments validated by the caller
331
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="spawned")
332
+ else:
333
+ command = ["xdg-open", url]
334
+ plan = command_plan(command, name="open_url", purpose="setup_wizard_open_url")
335
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="started")
336
+ subprocess.Popen(command)
337
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="spawned")
338
+ except Exception as exc:
339
+ try:
340
+ append_process_audit_event(
341
+ "setup_wizard_open_url",
342
+ plan=command_plan(["open_url", url], name="open_url", purpose="setup_wizard_open_url"),
343
+ status="error",
344
+ error=str(exc),
345
+ )
346
+ except Exception:
347
+ quiet()
348
+ pass
@@ -0,0 +1,168 @@
1
+ """PATH discovery, binary lookup, and the ``.env`` handoff for the wizard.
2
+
3
+ The base layer of the setup wizard: it knows where installers put things on
4
+ each platform, how to get those directories onto ``PATH`` for the running
5
+ process, and how to persist that repair into the project ``.env``. Nothing
6
+ here imports another wizard submodule, which is also what lets
7
+ ``tests/unit/test_cov_wp04_wizard_detect.py`` re-execute this file under a
8
+ throwaway name to reach the Windows-only import-time branch.
9
+
10
+ ``COMMON_PATH_DIRS`` is module-level state that the import-time Windows block
11
+ extends and tests replace, so it lives here and only here — the modules that
12
+ repair PATH call :func:`repair_path_for` rather than copying the list.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import platform
19
+ import shutil
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List
22
+
23
+ OFFICIAL_DOWNLOADS: Dict[str, str] = {
24
+ "homebrew": "https://brew.sh",
25
+ "python": "https://www.python.org/downloads/",
26
+ "node": "https://nodejs.org/en/download",
27
+ "git": "https://git-scm.com/downloads",
28
+ "ollama": "https://ollama.com/download",
29
+ "lmstudio": "https://lmstudio.ai/download",
30
+ "cuda": "https://developer.nvidia.com/cuda-downloads",
31
+ "mlx": "https://ml-explore.github.io/mlx/build/html/install.html",
32
+ "cloudflared": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
33
+ "tesseract": "https://tesseract-ocr.github.io/tessdoc/Installation.html",
34
+ }
35
+
36
+ COMMON_PATH_DIRS = [
37
+ "/opt/homebrew/bin",
38
+ "/usr/local/bin",
39
+ "/usr/bin",
40
+ "/bin",
41
+ str(Path.home() / ".local" / "bin"),
42
+ str(Path.home() / ".cargo" / "bin"),
43
+ str(Path.home() / ".latticeai" / "bin"),
44
+ ]
45
+
46
+ if platform.system() == "Windows":
47
+ _local_appdata = os.environ.get("LOCALAPPDATA", "")
48
+ _program_files = os.environ.get("ProgramFiles", r"C:\Program Files")
49
+ _program_files_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
50
+ COMMON_PATH_DIRS.extend([
51
+ str(Path(_local_appdata) / "Programs" / "Ollama") if _local_appdata else "",
52
+ str(Path(_program_files) / "Ollama"),
53
+ str(Path(_program_files) / "LM Studio"),
54
+ str(Path(_program_files) / "NVIDIA Corporation" / "NVSMI"),
55
+ str(Path(_program_files_x86) / "NVIDIA Corporation" / "NVSMI"),
56
+ ])
57
+ COMMON_PATH_DIRS = [p for p in COMMON_PATH_DIRS if p]
58
+
59
+ WINDOWS_BINARY_CANDIDATES: Dict[str, List[str]] = {
60
+ "ollama": [
61
+ str(Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "Ollama" / "ollama.exe"),
62
+ str(Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Ollama" / "ollama.exe"),
63
+ ],
64
+ "lms": [
65
+ str(Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe"),
66
+ str(Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe"),
67
+ ],
68
+ "nvidia-smi": [
69
+ str(Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe"),
70
+ str(Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe"),
71
+ ],
72
+ }
73
+
74
+ PACKAGE_MODULES: Dict[str, str] = {
75
+ "mlx-vlm": "mlx_vlm",
76
+ "huggingface_hub[cli]": "huggingface_hub",
77
+ "openai-whisper": "whisper",
78
+ }
79
+
80
+
81
+ def _project_env_file() -> Path:
82
+ return Path(__file__).resolve().parents[2] / ".env"
83
+
84
+
85
+ def _update_env_file(env_file: Path, key: str, value: str) -> None:
86
+ lines: List[str] = []
87
+ found = False
88
+ if env_file.exists():
89
+ lines = env_file.read_text(encoding="utf-8").splitlines()
90
+ updated: List[str] = []
91
+ for line in lines:
92
+ if line.startswith(f"{key}="):
93
+ updated.append(f"{key}={value}")
94
+ found = True
95
+ else:
96
+ updated.append(line)
97
+ if not found:
98
+ updated.append(f"{key}={value}")
99
+ env_file.write_text("\n".join(updated) + "\n", encoding="utf-8")
100
+
101
+
102
+ def _merge_path_dirs(dirs: List[str]) -> List[str]:
103
+ current = os.environ.get("PATH", "")
104
+ parts = [p for p in current.split(os.pathsep) if p]
105
+ for item in dirs:
106
+ expanded = str(Path(item).expanduser())
107
+ if Path(expanded).exists() and expanded not in parts:
108
+ parts.insert(0, expanded)
109
+ os.environ["PATH"] = os.pathsep.join(parts)
110
+ return parts
111
+
112
+
113
+ def _persist_extra_path(dirs: List[str]) -> None:
114
+ existing = [
115
+ p for p in os.environ.get("LATTICEAI_EXTRA_PATH", "").split(os.pathsep)
116
+ if p
117
+ ]
118
+ merged = existing[:]
119
+ for item in dirs:
120
+ expanded = str(Path(item).expanduser())
121
+ if Path(expanded).exists() and expanded not in merged:
122
+ merged.append(expanded)
123
+ if merged:
124
+ os.environ["LATTICEAI_EXTRA_PATH"] = os.pathsep.join(merged)
125
+ _update_env_file(_project_env_file(), "LATTICEAI_EXTRA_PATH", os.environ["LATTICEAI_EXTRA_PATH"])
126
+
127
+
128
+ def repair_path_for(binary: str | None = None) -> List[str]:
129
+ before = _which_any(binary) if binary else None
130
+ paths = _merge_path_dirs(COMMON_PATH_DIRS)
131
+ if binary and not before and _which_any(binary):
132
+ _persist_extra_path(COMMON_PATH_DIRS)
133
+ return paths
134
+
135
+
136
+ def _which_any(binary: str) -> str | None:
137
+ path = shutil.which(binary)
138
+ if path:
139
+ return path
140
+ if platform.system() == "Windows":
141
+ for candidate in WINDOWS_BINARY_CANDIDATES.get(binary, []):
142
+ if candidate and Path(candidate).exists():
143
+ return candidate
144
+ return None
145
+
146
+
147
+ def _which_detail(binary: str) -> Dict[str, Any]:
148
+ path = _which_any(binary)
149
+ return {"installed": path is not None, "path": path}
150
+
151
+
152
+ def _module_available(module_name: str) -> bool:
153
+ import importlib.util
154
+ return importlib.util.find_spec(module_name) is not None
155
+
156
+
157
+ def _package_module(package: str) -> str:
158
+ return PACKAGE_MODULES.get(package, package.replace("-", "_").split("[", 1)[0])
159
+
160
+
161
+ def _component_detail(name: str, binary: str | None = None, module: str | None = None) -> Dict[str, Any]:
162
+ detail: Dict[str, Any] = {"official_url": OFFICIAL_DOWNLOADS.get(name)}
163
+ if binary:
164
+ detail.update(_which_detail(binary))
165
+ if module:
166
+ detail["module_available"] = _module_available(module)
167
+ detail["installed"] = bool(detail.get("installed") or detail["module_available"])
168
+ return detail
@@ -0,0 +1,74 @@
1
+ """SSE framing and the confirmation-token plans for wizard install actions.
2
+
3
+ Every install action the wizard offers is turned into an explicit command plan
4
+ before it is shown, so the token the browser sends back can be compared against
5
+ the exact commands that would run. An action with nothing to execute has no
6
+ plan and therefore nothing to confirm.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json as _json
12
+ import sys
13
+ from typing import Any, Dict, List
14
+
15
+ from latticeai.services.process_audit import command_plan_for_commands
16
+
17
+
18
+ def _sse(data: Dict) -> str:
19
+ return f"data: {_json.dumps(data, ensure_ascii=False)}\n\n"
20
+
21
+
22
+ def _action_commands(action: Dict[str, Any]) -> List[List[str]]:
23
+ atype = action.get("type")
24
+ if atype == "pip":
25
+ return [[sys.executable, "-m", "pip", "install", "--upgrade", str(pkg)] for pkg in action.get("packages", [])]
26
+ if atype == "brew":
27
+ package = str(action.get("package") or "")
28
+ return [["brew", "install", package]] if package else []
29
+ return []
30
+
31
+
32
+ def _action_command_plan(action: Dict[str, Any], *, name: str) -> Dict[str, Any] | None:
33
+ commands = _action_commands(action)
34
+ if not commands:
35
+ return None
36
+ return command_plan_for_commands(
37
+ commands,
38
+ name=name,
39
+ purpose="setup_wizard_install",
40
+ metadata={"action_type": action.get("type")},
41
+ )
42
+
43
+
44
+ def _attach_action_plan(action: Dict[str, Any] | None, *, name: str) -> Dict[str, Any] | None:
45
+ if not isinstance(action, dict):
46
+ return action
47
+ plan = _action_command_plan(action, name=name)
48
+ if not plan:
49
+ return action
50
+ hydrated = dict(action)
51
+ hydrated["command_plan"] = plan
52
+ hydrated["confirmation_token"] = plan["confirmation_token"]
53
+ return hydrated
54
+
55
+
56
+ def _hydrate_install_actions(groups: Dict[str, Any]) -> Dict[str, Any]:
57
+ for group_name in ("components", "engines", "models", "mcps"):
58
+ items = groups.get(group_name)
59
+ if not isinstance(items, list):
60
+ continue
61
+ for item in items:
62
+ if isinstance(item, dict):
63
+ item["action"] = _attach_action_plan(
64
+ item.get("action"),
65
+ name=str(item.get("id") or item.get("name") or group_name),
66
+ )
67
+ return groups
68
+
69
+
70
+ def _verify_action_confirmation(action: Dict[str, Any], token: str | None, *, name: str) -> bool:
71
+ plan = _action_command_plan(action, name=name)
72
+ if not plan:
73
+ return True
74
+ return str(token or "").strip() == plan["confirmation_token"]