ltcai 10.8.0 → 10.9.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 (84) hide show
  1. package/README.md +54 -52
  2. package/docs/CHANGELOG.md +37 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/runtime/multi_agent.py +1 -1
  12. package/latticeai/__init__.py +1 -1
  13. package/latticeai/api/chat.py +9 -6
  14. package/latticeai/api/chat_agent_http.py +2 -1
  15. package/latticeai/api/chat_history.py +4 -2
  16. package/latticeai/api/chat_intents.py +14 -14
  17. package/latticeai/api/knowledge_graph.py +5 -4
  18. package/latticeai/api/local_files.py +25 -15
  19. package/latticeai/api/mcp.py +15 -11
  20. package/latticeai/api/memory.py +2 -1
  21. package/latticeai/api/models.py +39 -24
  22. package/latticeai/api/network_boundary.py +9 -4
  23. package/latticeai/api/portability.py +27 -19
  24. package/latticeai/api/project_sessions.py +7 -5
  25. package/latticeai/api/review_queue.py +12 -7
  26. package/latticeai/api/setup.py +10 -11
  27. package/latticeai/api/static_routes.py +62 -46
  28. package/latticeai/api/tools.py +7 -6
  29. package/latticeai/core/legacy_compatibility.py +1 -1
  30. package/latticeai/core/marketplace.py +1 -1
  31. package/latticeai/core/mcp_registry.py +20 -4
  32. package/latticeai/core/messages.py +222 -0
  33. package/latticeai/core/workspace_os_constants.py +1 -1
  34. package/latticeai/integrations/telegram_bot.py +20 -18
  35. package/latticeai/services/architecture_readiness.py +1 -1
  36. package/latticeai/services/model_loading.py +63 -27
  37. package/latticeai/services/product_readiness.py +1 -1
  38. package/package.json +3 -2
  39. package/scripts/check_current_release_docs.mjs +1 -1
  40. package/scripts/check_server_i18n.mjs +96 -0
  41. package/scripts/release_screen_claims.json +17 -8
  42. package/src-tauri/Cargo.lock +1 -1
  43. package/src-tauri/Cargo.toml +1 -1
  44. package/src-tauri/tauri.conf.json +1 -1
  45. package/static/app/asset-manifest.json +37 -37
  46. package/static/app/assets/{Act-CbQNsFgc.js → Act-CS9IeqUX.js} +1 -1
  47. package/static/app/assets/{AdminConsole-Bs8SR8fE.js → AdminConsole-3UkIEWGA.js} +1 -1
  48. package/static/app/assets/{Brain-BZIPsZlH.js → Brain-B22EmNqS.js} +1 -1
  49. package/static/app/assets/{BrainHome-BaH5MDce.js → BrainHome-CMDqgJF4.js} +2 -2
  50. package/static/app/assets/{BrainSignals-aDt-nSsW.js → BrainSignals-BeE8RJo3.js} +1 -1
  51. package/static/app/assets/{Capture-DDv940Bv.js → Capture-ZX9bQh68.js} +1 -1
  52. package/static/app/assets/{CommandPalette-Dq_MmPBl.js → CommandPalette-86m4FCcN.js} +1 -1
  53. package/static/app/assets/{Library-BkShbqmN.js → Library-Bhz5LUca.js} +1 -1
  54. package/static/app/assets/{LivingBrain-CXb8094H.js → LivingBrain-BSa0wpFG.js} +1 -1
  55. package/static/app/assets/{ProductFlow-BaiqFWLO.js → ProductFlow-IP4Q-5aQ.js} +1 -1
  56. package/static/app/assets/{ReviewCard-Be7E7X18.js → ReviewCard-BepjSDpN.js} +1 -1
  57. package/static/app/assets/{System-DCstbTxC.js → System-Bwx1h_jT.js} +1 -1
  58. package/static/app/assets/arrow-left-ig7AZU8B.js +1 -0
  59. package/static/app/assets/{bot-BR0eoeXY.js → bot-DQj0-LkM.js} +1 -1
  60. package/static/app/assets/{brain-BmWa1wea.js → brain-D8OEwmVj.js} +1 -1
  61. package/static/app/assets/{button-U1nU7xX4.js → button-CmTknyAP.js} +1 -1
  62. package/static/app/assets/{circle-pause-DG5KHjDL.js → circle-pause-yTCWRziJ.js} +1 -1
  63. package/static/app/assets/{circle-play-Chhcuq90.js → circle-play-Ccrva84R.js} +1 -1
  64. package/static/app/assets/{cpu-pkOSlT9h.js → cpu-CbJqWTlS.js} +1 -1
  65. package/static/app/assets/{download-CSRwz923.js → download-CkSzbzU-.js} +1 -1
  66. package/static/app/assets/{folder-open-DiMJ7W_7.js → folder-open-CKyjQ4PU.js} +1 -1
  67. package/static/app/assets/{hard-drive-iyLPAPdp.js → hard-drive-DAzk9um0.js} +1 -1
  68. package/static/app/assets/{index-BZRvGOM2.css → index-BfD-jhA9.css} +1 -1
  69. package/static/app/assets/{index-_ldE9TAX.js → index-CxOcwsHV.js} +3 -3
  70. package/static/app/assets/{input-e_r2hFAY.js → input-DcMETmZ7.js} +1 -1
  71. package/static/app/assets/{permissionCopy-9uOHktz3.js → permissionCopy-BVf13_25.js} +1 -1
  72. package/static/app/assets/{primitives-CnEAOmg2.js → primitives-CQV9Q2YM.js} +1 -1
  73. package/static/app/assets/search-CXGASMMH.js +1 -0
  74. package/static/app/assets/{share-2-DLDJJKmI.js → share-2-COWCHNZm.js} +1 -1
  75. package/static/app/assets/{shield-alert-BTtE-2EZ.js → shield-alert-BDrvilyK.js} +1 -1
  76. package/static/app/assets/{textarea-Bbzkcgqe.js → textarea-rUmsc8cP.js} +1 -1
  77. package/static/app/assets/{useFocusTrap-DJzQd-Za.js → useFocusTrap-Bi5UY_8v.js} +1 -1
  78. package/static/app/assets/{useQuery-6R_tqHRe.js → useQuery-C-AicB-3.js} +1 -1
  79. package/static/app/assets/{utils-XDsjcrhS.js → utils-BMwWg78e.js} +1 -1
  80. package/static/app/assets/{workspace-CwrG8LHq.js → workspace-Y93tls8P.js} +1 -1
  81. package/static/app/index.html +4 -4
  82. package/static/sw.js +1 -1
  83. package/static/app/assets/arrow-left-DC9Oquhs.js +0 -1
  84. package/static/app/assets/search-veep4n6f.js +0 -1
@@ -19,6 +19,7 @@ from typing import Any, Callable, Dict, List, Optional
19
19
  from fastapi import APIRouter, HTTPException, Request
20
20
  from pydantic import BaseModel, Field
21
21
 
22
+ from latticeai.core.messages import http_error, resolve_language, translate
22
23
  from latticeai.services.change_proposals import ProposalConflictError
23
24
  from latticeai.services.review_queue import InvalidReviewTransition, ReviewQueueService
24
25
 
@@ -129,7 +130,7 @@ def create_review_queue_router(
129
130
  try:
130
131
  return service.get(item_id, workspace_id=scope)
131
132
  except FileNotFoundError as exc:
132
- raise HTTPException(status_code=404, detail="review item not found") from exc
133
+ raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
133
134
 
134
135
  @router.post("/automation/reviews/{item_id}/approve", response_model=ReviewItem)
135
136
  async def approve_item(item_id: str, request: Request):
@@ -141,12 +142,16 @@ def create_review_queue_router(
141
142
  try:
142
143
  stored = service.get(item_id, workspace_id=scope)
143
144
  except FileNotFoundError as exc:
144
- raise HTTPException(status_code=404, detail="review item not found") from exc
145
+ raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
145
146
  if stored.get("source") == "change_proposal":
146
147
  if stored.get("effective_status") not in ("pending", "snoozed"):
147
148
  raise HTTPException(
148
149
  status_code=409,
149
- detail=f"cannot 'approve' a review item in status {stored.get('status')!r}",
150
+ detail=translate(
151
+ "review.cannot_approve_in_status",
152
+ resolve_language(request),
153
+ status=stored.get("status"),
154
+ ),
150
155
  )
151
156
  try:
152
157
  applied = change_proposals.approve_and_apply(
@@ -177,7 +182,7 @@ def create_review_queue_router(
177
182
  item_id, workspace_id=scope, reason=(req.reason if req else None)
178
183
  )
179
184
  except FileNotFoundError as exc:
180
- raise HTTPException(status_code=404, detail="review item not found") from exc
185
+ raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
181
186
  except InvalidReviewTransition as exc:
182
187
  raise HTTPException(status_code=409, detail=str(exc)) from exc
183
188
  append_audit_event("review_item_dismiss", user_email=user, item_id=item_id)
@@ -190,7 +195,7 @@ def create_review_queue_router(
190
195
  try:
191
196
  item = service.snooze(item_id, until=req.until, workspace_id=scope)
192
197
  except FileNotFoundError as exc:
193
- raise HTTPException(status_code=404, detail="review item not found") from exc
198
+ raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
194
199
  except InvalidReviewTransition as exc:
195
200
  raise HTTPException(status_code=409, detail=str(exc)) from exc
196
201
  append_audit_event("review_item_snooze", user_email=user, item_id=item_id)
@@ -211,7 +216,7 @@ def create_review_queue_router(
211
216
  workspace_id=scope,
212
217
  )
213
218
  except FileNotFoundError as exc:
214
- raise HTTPException(status_code=404, detail="review item not found") from exc
219
+ raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
215
220
  except InvalidReviewTransition as exc:
216
221
  raise HTTPException(status_code=409, detail=str(exc)) from exc
217
222
  append_audit_event("review_item_run_now", user_email=user, item_id=item_id)
@@ -224,7 +229,7 @@ def create_review_queue_router(
224
229
  try:
225
230
  item = fn(item_id, workspace_id=scope)
226
231
  except FileNotFoundError as exc:
227
- raise HTTPException(status_code=404, detail="review item not found") from exc
232
+ raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
228
233
  except InvalidReviewTransition as exc:
229
234
  raise HTTPException(status_code=409, detail=str(exc)) from exc
230
235
  append_audit_event(f"review_item_{action}", user_email=user, item_id=item_id)
@@ -9,6 +9,7 @@ from fastapi.responses import StreamingResponse
9
9
  from pydantic import BaseModel
10
10
 
11
11
  from lattice_brain.ingestion import IngestionItem
12
+ from latticeai.core.messages import http_error, resolve_language
12
13
  from latticeai.models.router import parse_model_ref
13
14
  from latticeai.services.process_audit import command_plan
14
15
  from latticeai.setup.auto_setup import (
@@ -184,27 +185,25 @@ def create_setup_router(
184
185
  }
185
186
  url = auth_urls.get(mcp_id)
186
187
  if not url:
187
- raise HTTPException(status_code=404, detail=f"알 수 없는 MCP: {mcp_id}")
188
+ raise http_error(404, "mcp.unknown_id", resolve_language(request), mcp_id=mcp_id)
188
189
  open_url(url)
189
190
  return {"status": "ok", "opened": url, "mcp_id": mcp_id}
190
191
 
191
192
 
192
193
  # ── First Value Loop demo corpus (backlog #3, review §3.3 P0) ────────────
193
194
 
194
- def _require_demo_pipeline():
195
+ def _require_demo_pipeline(request: Request):
195
196
  if ingestion_pipeline is None or not ingestion_pipeline.available():
196
- raise HTTPException(
197
- status_code=503, detail="Knowledge Graph ingestion is disabled.",
198
- )
197
+ raise http_error(503, "capture.ingestion_disabled", resolve_language(request))
199
198
  if knowledge_graph is None:
200
- raise HTTPException(status_code=503, detail="Knowledge Graph is disabled.")
199
+ raise http_error(503, "common.graph_disabled", resolve_language(request))
201
200
 
202
201
  def _demo_workspace(request: Request, body_workspace: Optional[str], user: str) -> Optional[str]:
203
202
  header = request.headers.get("X-Workspace-Id")
204
203
  header = header.strip() if header and header.strip() else None
205
204
  supplied = [value for value in (body_workspace, header) if value]
206
205
  if len(set(supplied)) > 1:
207
- raise HTTPException(status_code=403, detail="Workspace selectors must match.")
206
+ raise http_error(403, "common.workspace_mismatch", resolve_language(request))
208
207
  requested = supplied[0] if supplied else None
209
208
  if workspace_service is None:
210
209
  return requested
@@ -217,7 +216,7 @@ def create_setup_router(
217
216
  async def demo_corpus_status(request: Request):
218
217
  """Whether the demo corpus is installed + the suggestion chips."""
219
218
  require_user(request)
220
- _require_demo_pipeline()
219
+ _require_demo_pipeline(request)
221
220
  installed = knowledge_graph.find_documents_by_uri_prefix(DEMO_URI_PREFIX)
222
221
  return {
223
222
  "installed": bool(installed),
@@ -236,7 +235,7 @@ def create_setup_router(
236
235
  instead of duplicating.
237
236
  """
238
237
  user = require_user(request)
239
- _require_demo_pipeline()
238
+ _require_demo_pipeline(request)
240
239
  workspace_id = _demo_workspace(request, req.workspace_id if req else None, user)
241
240
  results = []
242
241
  ingested = 0
@@ -285,7 +284,7 @@ def create_setup_router(
285
284
  async def demo_corpus_remove(request: Request):
286
285
  """Remove every demo document (node + chunks + edges + orphan source)."""
287
286
  require_user(request)
288
- _require_demo_pipeline()
287
+ _require_demo_pipeline(request)
289
288
  installed = knowledge_graph.find_documents_by_uri_prefix(DEMO_URI_PREFIX)
290
289
  removed = []
291
290
  for doc in installed:
@@ -314,7 +313,7 @@ def create_setup_router(
314
313
  }
315
314
  url = urls.get(permission_id)
316
315
  if not url:
317
- raise HTTPException(status_code=404, detail="알 수 없는 권한 설정입니다.")
316
+ raise http_error(404, "setup.unknown_permission", resolve_language(request))
318
317
  open_url(url)
319
318
  return {"status": "ok", "opened": url, "permission": permission_id}
320
319
  return api_router
@@ -2,8 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import hashlib
6
7
  import hmac
8
+ import re
7
9
  import secrets
8
10
  import subprocess
9
11
  import time
@@ -40,6 +42,59 @@ SYSINFO_READINESS_ROOMY_MAX = 55.0
40
42
  SYSINFO_READINESS_TIGHT_MAX = 80.0
41
43
 
42
44
 
45
+ def _probe_host_capacity() -> Dict[str, Any]:
46
+ """Sample CPU / RAM / GPU on this machine. Blocking — call off the loop.
47
+
48
+ Extracted from the route so the subprocess sampling can be handed to a
49
+ worker thread, and so the parsing is testable without an HTTP request.
50
+ """
51
+ result: Dict[str, Any] = {
52
+ "cpu_pct": 0.0,
53
+ "ram_pct": 0.0,
54
+ "gpu_mem_pct": 0.0,
55
+ "gpu_mem_gb": 0.0,
56
+ "readiness": "roomy",
57
+ }
58
+ try:
59
+ # CPU
60
+ top_out = subprocess.run(["top", "-l", "1", "-n", "0"], capture_output=True, text=True, timeout=4).stdout
61
+ for line in top_out.splitlines():
62
+ if "CPU usage" in line:
63
+ m = re.search(r"([\d.]+)% user.*?([\d.]+)% sys", line)
64
+ if m:
65
+ result["cpu_pct"] = round(float(m.group(1)) + float(m.group(2)), 1)
66
+ # RAM
67
+ vm_out = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=4).stdout
68
+ pages: dict = {}
69
+ for line in vm_out.splitlines():
70
+ for key in ["Pages free", "Pages active", "Pages inactive", "Pages wired down", "Pages occupied by compressor"]:
71
+ if line.startswith(key):
72
+ m = re.search(r"(\d+)", line)
73
+ if m:
74
+ pages[key] = int(m.group(1))
75
+ total = sum(pages.values())
76
+ used = total - pages.get("Pages free", 0)
77
+ result["ram_pct"] = round(used / total * 100, 1) if total else 0.0
78
+ # GPU (MLX / Apple Silicon unified memory)
79
+ try:
80
+ import mlx.core as _mx
81
+ hw_out = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=2).stdout
82
+ total_bytes = int(hw_out.strip())
83
+ gpu_bytes = _mx.get_active_memory() + _mx.get_cache_memory()
84
+ result["gpu_mem_gb"] = round(gpu_bytes / (1024 ** 3), 2)
85
+ result["gpu_mem_pct"] = round(gpu_bytes / total_bytes * 100, 1) if total_bytes else 0.0
86
+ except Exception:
87
+ quiet()
88
+ except Exception as e:
89
+ result["error"] = str(e)
90
+ result["readiness"] = host_capacity_readiness(
91
+ cpu_pct=float(result.get("cpu_pct") or 0.0),
92
+ ram_pct=float(result.get("ram_pct") or 0.0),
93
+ gpu_mem_pct=float(result.get("gpu_mem_pct") or 0.0),
94
+ )
95
+ return result
96
+
97
+
43
98
  def host_capacity_readiness(
44
99
  *,
45
100
  cpu_pct: float = 0.0,
@@ -278,54 +333,15 @@ def create_static_routes_router(
278
333
 
279
334
  ``readiness`` is ``roomy`` | ``tight`` | ``low`` so basic System copy
280
335
  does not re-interpret raw percents on the client.
336
+
337
+ The probe itself is three subprocesses — ``top -l 1`` alone samples for
338
+ about a second — so it runs on a worker thread. Run inline it held the
339
+ event loop for the whole sample, and this route is hit on the System
340
+ screen *and* during first-run analysis, i.e. exactly while an answer is
341
+ streaming: one status poll used to stall every open stream.
281
342
  """
282
343
  require_user(request)
283
- import re as _re
284
- result: Dict[str, Any] = {
285
- "cpu_pct": 0.0,
286
- "ram_pct": 0.0,
287
- "gpu_mem_pct": 0.0,
288
- "gpu_mem_gb": 0.0,
289
- "readiness": "roomy",
290
- }
291
- try:
292
- # CPU
293
- top_out = subprocess.run(["top", "-l", "1", "-n", "0"], capture_output=True, text=True, timeout=4).stdout
294
- for line in top_out.splitlines():
295
- if "CPU usage" in line:
296
- m = _re.search(r"([\d.]+)% user.*?([\d.]+)% sys", line)
297
- if m:
298
- result["cpu_pct"] = round(float(m.group(1)) + float(m.group(2)), 1)
299
- # RAM
300
- vm_out = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=4).stdout
301
- pages: dict = {}
302
- for line in vm_out.splitlines():
303
- for key in ["Pages free", "Pages active", "Pages inactive", "Pages wired down", "Pages occupied by compressor"]:
304
- if line.startswith(key):
305
- m = _re.search(r"(\d+)", line)
306
- if m:
307
- pages[key] = int(m.group(1))
308
- total = sum(pages.values())
309
- used = total - pages.get("Pages free", 0)
310
- result["ram_pct"] = round(used / total * 100, 1) if total else 0.0
311
- # GPU (MLX / Apple Silicon unified memory)
312
- try:
313
- import mlx.core as _mx
314
- hw_out = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=2).stdout
315
- total_bytes = int(hw_out.strip())
316
- gpu_bytes = _mx.get_active_memory() + _mx.get_cache_memory()
317
- result["gpu_mem_gb"] = round(gpu_bytes / (1024 ** 3), 2)
318
- result["gpu_mem_pct"] = round(gpu_bytes / total_bytes * 100, 1) if total_bytes else 0.0
319
- except Exception:
320
- quiet()
321
- except Exception as e:
322
- result["error"] = str(e)
323
- result["readiness"] = host_capacity_readiness(
324
- cpu_pct=float(result.get("cpu_pct") or 0.0),
325
- ram_pct=float(result.get("ram_pct") or 0.0),
326
- gpu_mem_pct=float(result.get("gpu_mem_pct") or 0.0),
327
- )
328
- return result
344
+ return await asyncio.to_thread(_probe_host_capacity)
329
345
 
330
346
  return StaticRoutesBundle(
331
347
  api_router,
@@ -19,6 +19,7 @@ from latticeai.api.computer_use import create_computer_use_router
19
19
  from latticeai.api.local_files import create_local_files_router
20
20
  from latticeai.api.mcp import create_mcp_router
21
21
  from latticeai.api.permissions import create_permissions_router
22
+ from latticeai.core.messages import http_error, resolve_language
22
23
  from latticeai.services.router_context import ToolRouterContext
23
24
  from latticeai.services.tool_dispatch import (
24
25
  TOOL_GOVERNANCE,
@@ -510,7 +511,7 @@ def create_tools_router(
510
511
  )
511
512
  target = Path(path).expanduser().resolve()
512
513
  if not target.exists() or not target.is_file():
513
- raise HTTPException(status_code=404, detail="File not found")
514
+ raise http_error(404, "common.file_not_found", resolve_language(request))
514
515
  import pypdfium2 as pdfium
515
516
  doc = None
516
517
  try:
@@ -527,7 +528,7 @@ def create_tools_router(
527
528
  pages.append({"page": i + 1, "b64": b64})
528
529
  return {"total": total, "pages": pages}
529
530
  except Exception as e:
530
- raise HTTPException(status_code=500, detail=f"PDF 렌더링 실패: {e}")
531
+ raise http_error(500, "tools.pdf_render_failed", resolve_language(request), reason=e)
531
532
  finally:
532
533
  if doc is not None:
533
534
  try:
@@ -544,9 +545,9 @@ def create_tools_router(
544
545
  rel = unquote(path).lstrip("/")
545
546
  target = (AGENT_ROOT / rel).resolve()
546
547
  if AGENT_ROOT not in target.parents and target != AGENT_ROOT:
547
- raise HTTPException(status_code=403, detail="경로가 작업 공간 밖입니다.")
548
+ raise http_error(403, "tools.path_outside_workspace", resolve_language(request))
548
549
  if not target.exists() or not target.is_file():
549
- raise HTTPException(status_code=404, detail="파일이 없습니다.")
550
+ raise http_error(404, "common.file_not_found", resolve_language(request))
550
551
  return FileResponse(
551
552
  path=target,
552
553
  filename=target.name,
@@ -570,9 +571,9 @@ def create_tools_router(
570
571
  rel = unquote(path).lstrip("/")
571
572
  target = (AGENT_ROOT / rel).resolve()
572
573
  if AGENT_ROOT not in target.parents and target != AGENT_ROOT:
573
- raise HTTPException(status_code=403, detail="경로가 작업 공간 밖입니다.")
574
+ raise http_error(403, "tools.path_outside_workspace", resolve_language(request))
574
575
  if not target.exists() or not target.is_dir():
575
- raise HTTPException(status_code=404, detail="디렉터리가 없습니다.")
576
+ raise http_error(404, "tools.directory_not_found", resolve_language(request))
576
577
  try:
577
578
  payload, filename = zip_workspace_dir(rel)
578
579
  except ToolError as exc:
@@ -13,7 +13,7 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
- LEGACY_COMPATIBILITY_VERSION = "10.8.0"
16
+ LEGACY_COMPATIBILITY_VERSION = "10.9.0"
17
17
 
18
18
 
19
19
  @dataclass(frozen=True)
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
- MARKETPLACE_VERSION = "10.8.0"
13
+ MARKETPLACE_VERSION = "10.9.0"
14
14
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
15
15
 
16
16
 
@@ -4,6 +4,7 @@ Lattice AI — MCP Registry data & pure helper functions.
4
4
  Extracted from server.py to reduce module size.
5
5
  """
6
6
 
7
+ import asyncio
7
8
  import json
8
9
  import logging
9
10
  import os
@@ -354,6 +355,13 @@ async def install_skill(plugin: str, skill: str) -> Dict:
354
355
  }
355
356
 
356
357
 
358
+ def _run_installer(command: List[str], timeout: int) -> "subprocess.CompletedProcess[str]":
359
+ """Run one package-installer command. Blocking — call off the event loop."""
360
+ return subprocess.run(
361
+ command, capture_output=True, text=True, timeout=timeout, check=False
362
+ )
363
+
364
+
357
365
  def create_mcp_install_state(data_dir: Path) -> Dict[str, Callable]:
358
366
  """Return bound MCP state helpers for given data_dir. No global side effects."""
359
367
  MCP_FILE = Path(data_dir) / "mcp_installs.json"
@@ -445,11 +453,15 @@ def create_mcp_install_state(data_dir: Path) -> Dict[str, Callable]:
445
453
  status = "needs_auth"
446
454
  message = "커넥터 인증이 필요합니다. Codex 앱의 connector 설정에서 계정을 연결하면 바로 사용할 수 있습니다."
447
455
  elif item.get("install_mode") == "pip":
456
+ # Package installers are minutes of network I/O. On the event loop
457
+ # they froze every other request for the whole install; each one now
458
+ # runs on a worker thread instead.
448
459
  packages = item.get("pip_packages") or []
449
460
  for pkg in packages:
450
- completed = subprocess.run(
461
+ completed = await asyncio.to_thread(
462
+ _run_installer,
451
463
  [sys.executable, "-m", "pip", "install", "--upgrade", pkg],
452
- capture_output=True, text=True, timeout=900, check=False,
464
+ 900,
453
465
  )
454
466
  if completed.returncode != 0:
455
467
  raise HTTPException(status_code=500, detail=(completed.stderr or "")[-2000:] or f"{pkg} 설치 실패")
@@ -458,7 +470,9 @@ def create_mcp_install_state(data_dir: Path) -> Dict[str, Callable]:
458
470
  pkg = item.get("package", "")
459
471
  version = item.get("package_version")
460
472
  pkg_str = f"{pkg}=={version}" if version else pkg
461
- completed = subprocess.run([sys.executable, "-m", "pip", "install", pkg_str], capture_output=True, text=True, timeout=300, check=False)
473
+ completed = await asyncio.to_thread(
474
+ _run_installer, [sys.executable, "-m", "pip", "install", pkg_str], 300
475
+ )
462
476
  if completed.returncode != 0:
463
477
  raise HTTPException(status_code=500, detail=(completed.stderr or "")[-2000:] or f"{pkg} 설치 실패")
464
478
  message = f"pip 패키지 설치 완료: {pkg_str}"
@@ -466,7 +480,9 @@ def create_mcp_install_state(data_dir: Path) -> Dict[str, Callable]:
466
480
  pkg = item.get("package", "")
467
481
  version = item.get("package_version")
468
482
  pkg_str = f"{pkg}@{version}" if version else pkg
469
- completed = subprocess.run(["npm", "install", "-g", pkg_str], capture_output=True, text=True, timeout=300, check=False)
483
+ completed = await asyncio.to_thread(
484
+ _run_installer, ["npm", "install", "-g", pkg_str], 300
485
+ )
470
486
  if completed.returncode != 0:
471
487
  raise HTTPException(status_code=500, detail=(completed.stderr or "")[-2000:] or f"{pkg} 설치 실패")
472
488
  message = f"npm 패키지 설치 완료: {pkg_str}"
@@ -155,6 +155,228 @@ MESSAGES: Dict[str, Dict[str, str]] = {
155
155
  "ko": "저장할 내용이 없습니다. 텍스트나 페이지 내용을 함께 보내주세요.",
156
156
  "en": "Nothing to capture — send text, html, or a selection.",
157
157
  },
158
+ # ── shared ──────────────────────────────────────────────────────────
159
+ "common.user_mismatch": {
160
+ "ko": "로그인한 사용자와 요청한 사용자가 다릅니다.",
161
+ "en": "user_email must match the authenticated user.",
162
+ },
163
+ "common.workspace_mismatch": {
164
+ "ko": "작업 공간 지정이 서로 다릅니다.",
165
+ "en": "Workspace selectors must match.",
166
+ },
167
+ "common.workspace_unreadable": {
168
+ "ko": "'{workspace}' 작업 공간을 읽을 권한이 없습니다.",
169
+ "en": "Workspace '{workspace}' is not readable.",
170
+ },
171
+ "common.file_not_found": {
172
+ "ko": "파일을 찾을 수 없습니다.",
173
+ "en": "File not found.",
174
+ },
175
+ "common.path_required": {
176
+ "ko": "경로를 입력해주세요.",
177
+ "en": "A path is required.",
178
+ },
179
+ "common.graph_disabled": {
180
+ "ko": "지식 그래프가 꺼져 있습니다.",
181
+ "en": "The Knowledge Graph is turned off.",
182
+ },
183
+ "common.graph_unavailable": {
184
+ "ko": "지식 그래프를 사용할 수 없습니다.",
185
+ "en": "The Knowledge Graph is not available.",
186
+ },
187
+ # ── chat ────────────────────────────────────────────────────────────
188
+ "chat.model_not_loaded": {
189
+ "ko": "'{model}' 모델이 아직 준비되지 않았습니다.",
190
+ "en": "Model '{model}' is not loaded.",
191
+ },
192
+ "chat.no_model_loaded": {
193
+ "ko": "불러온 모델이 없습니다. 모델을 먼저 준비해주세요.",
194
+ "en": "No model is loaded. Prepare a model first.",
195
+ },
196
+ "chat.conversation_not_found": {
197
+ "ko": "대화를 찾을 수 없습니다.",
198
+ "en": "That conversation no longer exists.",
199
+ },
200
+ "chat.file_name_collision": {
201
+ "ko": "'{name}' 이름의 파일이 너무 많습니다. 다른 이름을 지정해 주세요.",
202
+ "en": "Too many files are already named '{name}'. Please choose another name.",
203
+ },
204
+ "chat.file_generation_failed": {
205
+ "ko": "파일 내용을 만들지 못했습니다.",
206
+ "en": "The file content could not be generated.",
207
+ },
208
+ # ── memory / graph ──────────────────────────────────────────────────
209
+ "memory.unknown_source": {
210
+ "ko": "'{source}'는 알 수 없는 기억 출처입니다.",
211
+ "en": "Unknown memory source: {source}.",
212
+ },
213
+ "graph.node_id_required": {
214
+ "ko": "노드 식별자가 필요합니다.",
215
+ "en": "A node id is required.",
216
+ },
217
+ "graph.node_not_found": {
218
+ "ko": "노드를 찾을 수 없습니다.",
219
+ "en": "That node was not found.",
220
+ },
221
+ "graph.unsupported_type": {
222
+ "ko": "지원하는 종류: message, ai_response, note",
223
+ "en": "Supported types are: message, ai_response, note.",
224
+ },
225
+ # ── ingestion / folders ─────────────────────────────────────────────
226
+ "ingestion.job_not_found": {
227
+ "ko": "수집 작업을 찾을 수 없습니다.",
228
+ "en": "That ingestion job was not found.",
229
+ },
230
+ "ingestion.watch_unavailable": {
231
+ "ko": "폴더 감시 기능을 사용할 수 없습니다.",
232
+ "en": "The folder watch service is unavailable.",
233
+ },
234
+ "ingestion.watch_enable_failed": {
235
+ "ko": "폴더 감시를 켜지 못했습니다.",
236
+ "en": "Turning folder watch on did not succeed.",
237
+ },
238
+ "ingestion.watch_selector_required": {
239
+ "ko": "감시 항목 또는 경로가 필요합니다.",
240
+ "en": "A watch id or a path is required.",
241
+ },
242
+ "ingestion.watch_not_found": {
243
+ "ko": "감시 중인 폴더를 찾을 수 없습니다.",
244
+ "en": "That watched folder was not found.",
245
+ },
246
+ "portability.verification_failed": {
247
+ "ko": "보관 파일 검증에 실패했습니다.",
248
+ "en": "Archive verification failed.",
249
+ },
250
+ # ── review queue ────────────────────────────────────────────────────
251
+ "review.item_not_found": {
252
+ "ko": "검토 항목을 찾을 수 없습니다.",
253
+ "en": "That review item was not found.",
254
+ },
255
+ "review.cannot_approve_in_status": {
256
+ "ko": "'{status}' 상태의 검토 항목은 승인할 수 없습니다.",
257
+ "en": "A review item in status '{status}' cannot be approved.",
258
+ },
259
+ # ── projects ────────────────────────────────────────────────────────
260
+ "project.not_found": {
261
+ "ko": "프로젝트를 찾을 수 없습니다.",
262
+ "en": "That project was not found.",
263
+ },
264
+ # ── network boundary ────────────────────────────────────────────────
265
+ "boundary.policy_not_configured": {
266
+ "ko": "혼합 검색 정책 서비스가 설정되지 않았습니다.",
267
+ "en": "The hybrid policy service is not configured.",
268
+ },
269
+ # ── models ──────────────────────────────────────────────────────────
270
+ "models.other_user_credentials": {
271
+ "ko": "다른 사용자의 모델 자격 증명을 사용할 수 없습니다.",
272
+ "en": "You cannot use another user's model credentials.",
273
+ },
274
+ "models.download_consent_required": {
275
+ "ko": "모델 내려받기는 사용자가 직접 동의해야 시작됩니다.",
276
+ "en": "Model downloads require explicit consent (allow_download=true).",
277
+ },
278
+ "models.identifier_empty": {
279
+ "ko": "모델 식별자가 비어 있습니다.",
280
+ "en": "The model identifier is empty.",
281
+ },
282
+ "models.name_empty": {
283
+ "ko": "모델 이름이 비어 있습니다.",
284
+ "en": "The model name is empty.",
285
+ },
286
+ "models.ollama_missing": {
287
+ "ko": "Ollama가 설치되어 있지 않습니다.",
288
+ "en": "Ollama is not installed.",
289
+ },
290
+ "models.download_timeout": {
291
+ "ko": "모델 내려받기 시간이 초과되었습니다.",
292
+ "en": "The model download timed out.",
293
+ },
294
+ "models.pull_failed": {
295
+ "ko": "모델 내려받기에 실패했습니다.",
296
+ "en": "The model download failed.",
297
+ },
298
+ "models.download_not_automated": {
299
+ "ko": "{provider} 엔진의 모델 내려받기는 아직 자동화되지 않았습니다.",
300
+ "en": "Model downloads for the {provider} engine are not automated yet.",
301
+ },
302
+ "models.unknown_provider": {
303
+ "ko": "알 수 없는 제공자입니다.",
304
+ "en": "Unknown provider.",
305
+ },
306
+ "models.api_key_empty": {
307
+ "ko": "API 키가 비어 있습니다.",
308
+ "en": "The API key is empty.",
309
+ },
310
+ "models.other_user_api_key": {
311
+ "ko": "다른 사용자의 API 키를 설정할 권한이 없습니다.",
312
+ "en": "You are not allowed to set another user's API key.",
313
+ },
314
+ "models.sign_in_required": {
315
+ "ko": "사용자 확인이 필요합니다. 로그인 후 다시 시도하세요.",
316
+ "en": "Sign in first, then try again.",
317
+ },
318
+ # ── tools / files ───────────────────────────────────────────────────
319
+ "tools.pdf_render_failed": {
320
+ "ko": "PDF를 그리지 못했습니다: {reason}",
321
+ "en": "The PDF could not be rendered: {reason}",
322
+ },
323
+ "tools.path_outside_workspace": {
324
+ "ko": "경로가 작업 공간 밖입니다.",
325
+ "en": "That path is outside the workspace.",
326
+ },
327
+ "tools.directory_not_found": {
328
+ "ko": "디렉터리를 찾을 수 없습니다.",
329
+ "en": "That directory was not found.",
330
+ },
331
+ # ── MCP / setup ─────────────────────────────────────────────────────
332
+ "mcp.connector_not_found": {
333
+ "ko": "커넥터를 찾을 수 없습니다.",
334
+ "en": "That connector was not found.",
335
+ },
336
+ "mcp.name_required": {
337
+ "ko": "이름은 필수입니다.",
338
+ "en": "A name is required.",
339
+ },
340
+ "mcp.package_required": {
341
+ "ko": "패키지는 필수입니다.",
342
+ "en": "A package is required.",
343
+ },
344
+ "mcp.item_not_found": {
345
+ "ko": "항목을 찾을 수 없습니다.",
346
+ "en": "That item was not found.",
347
+ },
348
+ "mcp.unknown_id": {
349
+ "ko": "알 수 없는 MCP입니다: {mcp_id}",
350
+ "en": "Unknown MCP: {mcp_id}",
351
+ },
352
+ "setup.unknown_permission": {
353
+ "ko": "알 수 없는 권한 설정입니다.",
354
+ "en": "Unknown permission setting.",
355
+ },
356
+ "models.public_mode_blocks_local": {
357
+ "ko": (
358
+ "공개 모드에서는 로컬 MLX 모델을 불러올 수 없습니다. "
359
+ "openai:, openrouter:, groq:, together: 모델을 쓰거나 "
360
+ "LATTICEAI_ALLOW_LOCAL_MODELS=true 로 설정하세요."
361
+ ),
362
+ "en": (
363
+ "Public mode does not load local MLX models. Use an openai:, "
364
+ "openrouter:, groq: or together: model, or set "
365
+ "LATTICEAI_ALLOW_LOCAL_MODELS=true."
366
+ ),
367
+ },
368
+ "models.public_model_missing": {
369
+ "ko": (
370
+ "공개 모델이 준비되지 않았습니다. OPENAI_API_KEY 와 "
371
+ "LATTICEAI_PUBLIC_MODEL={model} 을 설정하거나, OpenAI 호환 모델로 "
372
+ "/models/load 를 호출하세요."
373
+ ),
374
+ "en": (
375
+ "No public model is loaded. Set OPENAI_API_KEY and "
376
+ "LATTICEAI_PUBLIC_MODEL={model}, or call /models/load with an "
377
+ "OpenAI-compatible model."
378
+ ),
379
+ },
158
380
  }
159
381
 
160
382
 
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  from typing import Dict
12
12
 
13
- WORKSPACE_OS_VERSION = "10.8.0"
13
+ WORKSPACE_OS_VERSION = "10.9.0"
14
14
 
15
15
  # Workspace types separate single-user Personal workspaces from shared
16
16
  # Organization workspaces. Both keep the same local-first JSON store; the type