ltcai 10.8.0 → 10.10.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 (94) hide show
  1. package/README.md +44 -63
  2. package/docs/CHANGELOG.md +69 -237
  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 +5 -3
  38. package/package.json +3 -2
  39. package/scripts/check_current_release_docs.mjs +4 -2
  40. package/scripts/check_server_i18n.mjs +96 -0
  41. package/scripts/release_screen_claims.json +31 -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-CC8SpNRm.js} +1 -1
  47. package/static/app/assets/{AdminConsole-Bs8SR8fE.js → AdminConsole-DGIY_Tb_.js} +1 -1
  48. package/static/app/assets/{Brain-BZIPsZlH.js → Brain-BDSC2Mol.js} +2 -2
  49. package/static/app/assets/BrainHome-BYgYp3ON.js +2 -0
  50. package/static/app/assets/BrainSignals-DYwVFmcO.js +1 -0
  51. package/static/app/assets/Capture-ClcKLsmI.js +1 -0
  52. package/static/app/assets/{CommandPalette-Dq_MmPBl.js → CommandPalette-DpwbyF1S.js} +1 -1
  53. package/static/app/assets/Library-D8VNtffM.js +1 -0
  54. package/static/app/assets/LivingBrain-CeWwc0NN.js +1 -0
  55. package/static/app/assets/ProductFlow-CS5CCM4t.js +1 -0
  56. package/static/app/assets/{ReviewCard-Be7E7X18.js → ReviewCard-Bap4yPfI.js} +1 -1
  57. package/static/app/assets/System-odPxU1YL.js +1 -0
  58. package/static/app/assets/arrow-left-J47Yde1U.js +1 -0
  59. package/static/app/assets/{bot-BR0eoeXY.js → bot-CmvhqTeF.js} +1 -1
  60. package/static/app/assets/brain-DLT9uuGq.js +1 -0
  61. package/static/app/assets/{button-U1nU7xX4.js → button-DyppYhFH.js} +1 -1
  62. package/static/app/assets/{circle-pause-DG5KHjDL.js → circle-pause-Bm5z7EBI.js} +1 -1
  63. package/static/app/assets/{circle-play-Chhcuq90.js → circle-play-GjKdH0Fg.js} +1 -1
  64. package/static/app/assets/{cpu-pkOSlT9h.js → cpu-CuVMv-F5.js} +1 -1
  65. package/static/app/assets/{download-CSRwz923.js → download-C9_946tf.js} +1 -1
  66. package/static/app/assets/{folder-open-DiMJ7W_7.js → folder-open-B3QKdxib.js} +1 -1
  67. package/static/app/assets/{hard-drive-iyLPAPdp.js → hard-drive-BwW9Hx4r.js} +1 -1
  68. package/static/app/assets/{index-_ldE9TAX.js → index-BrUQ-bF8.js} +3 -3
  69. package/static/app/assets/index-CYb18LtP.css +2 -0
  70. package/static/app/assets/{input-e_r2hFAY.js → input-Db2Hw2VH.js} +1 -1
  71. package/static/app/assets/{permissionCopy-9uOHktz3.js → permissionCopy-BpbIHRio.js} +1 -1
  72. package/static/app/assets/primitives-4B_Ynf7r.js +1 -0
  73. package/static/app/assets/search-YH-7-x5Y.js +1 -0
  74. package/static/app/assets/{share-2-DLDJJKmI.js → share-2-BgvRTDPJ.js} +1 -1
  75. package/static/app/assets/{shield-alert-BTtE-2EZ.js → shield-alert-DWFLyykG.js} +1 -1
  76. package/static/app/assets/{textarea-Bbzkcgqe.js → textarea-Bk4vEqzT.js} +1 -1
  77. package/static/app/assets/{useFocusTrap-DJzQd-Za.js → useFocusTrap-CGRXEg_p.js} +1 -1
  78. package/static/app/assets/{useQuery-6R_tqHRe.js → useQuery-CdPzp3_U.js} +1 -1
  79. package/static/app/assets/{utils-XDsjcrhS.js → utils-orU8igel.js} +3 -3
  80. package/static/app/assets/{workspace-CwrG8LHq.js → workspace-Brj2Ylb2.js} +1 -1
  81. package/static/app/index.html +4 -4
  82. package/static/sw.js +1 -1
  83. package/static/app/assets/BrainHome-BaH5MDce.js +0 -2
  84. package/static/app/assets/BrainSignals-aDt-nSsW.js +0 -1
  85. package/static/app/assets/Capture-DDv940Bv.js +0 -1
  86. package/static/app/assets/Library-BkShbqmN.js +0 -1
  87. package/static/app/assets/LivingBrain-CXb8094H.js +0 -1
  88. package/static/app/assets/ProductFlow-BaiqFWLO.js +0 -1
  89. package/static/app/assets/System-DCstbTxC.js +0 -1
  90. package/static/app/assets/arrow-left-DC9Oquhs.js +0 -1
  91. package/static/app/assets/brain-BmWa1wea.js +0 -1
  92. package/static/app/assets/index-BZRvGOM2.css +0 -2
  93. package/static/app/assets/primitives-CnEAOmg2.js +0 -1
  94. package/static/app/assets/search-veep4n6f.js +0 -1
@@ -12,6 +12,8 @@ from typing import Any, Callable, Optional
12
12
  from fastapi import APIRouter, HTTPException, Request
13
13
  from pydantic import BaseModel
14
14
 
15
+ from latticeai.core.messages import http_error, resolve_language, translate
16
+
15
17
 
16
18
  class ImportRequest(BaseModel):
17
19
  artifact: dict
@@ -72,52 +74,52 @@ def create_portability_router(
72
74
  ) -> APIRouter:
73
75
  router = APIRouter()
74
76
 
75
- def _require_service():
77
+ def _require_service(request: Request):
76
78
  if service is None or not service.available():
77
- raise HTTPException(status_code=503, detail="Knowledge Graph is disabled.")
79
+ raise http_error(503, "common.graph_disabled", resolve_language(request))
78
80
 
79
81
  @router.get("/api/knowledge-graph/portability")
80
82
  async def portability_status(request: Request):
81
83
  require_user(request)
82
- _require_service()
84
+ _require_service(request)
83
85
  return service.snapshot_metadata()
84
86
 
85
87
  @router.get("/api/brain/storage")
86
88
  async def brain_storage_status(request: Request):
87
89
  require_user(request)
88
- _require_service()
90
+ _require_service(request)
89
91
  return service.storage_status()
90
92
 
91
93
  @router.get("/api/knowledge-graph/backup-health")
92
94
  async def backup_health(request: Request):
93
95
  require_user(request)
94
- _require_service()
96
+ _require_service(request)
95
97
  return service.backup_health()
96
98
 
97
99
  @router.get("/api/knowledge-graph/provenance")
98
100
  async def recent_provenance(request: Request, limit: int = 50, source_type: Optional[str] = None):
99
101
  """Recent ingestions (provenance trail) for the ingestion-sources UI."""
100
102
  require_admin(request)
101
- _require_service()
103
+ _require_service(request)
102
104
  return service.recent_ingestions(limit=limit, source_type=source_type)
103
105
 
104
106
  @router.post("/api/knowledge-graph/export")
105
107
  async def export_graph(request: Request):
106
108
  """Logical JSON export of the whole graph (read-only)."""
107
109
  require_admin(request)
108
- _require_service()
110
+ _require_service(request)
109
111
  return service.export()
110
112
 
111
113
  @router.post("/api/knowledge-graph/export-file")
112
114
  async def export_graph_file(request: Request):
113
115
  require_admin(request)
114
- _require_service()
116
+ _require_service(request)
115
117
  return service.export_to_file()
116
118
 
117
119
  @router.post("/api/knowledge-graph/import")
118
120
  async def import_graph(req: ImportRequest, request: Request):
119
121
  require_admin(request)
120
- _require_service()
122
+ _require_service(request)
121
123
  try:
122
124
  return service.import_data(req.artifact, mode=req.mode, dry_run=req.dry_run)
123
125
  except ValueError as exc:
@@ -126,13 +128,13 @@ def create_portability_router(
126
128
  @router.post("/api/knowledge-graph/backup")
127
129
  async def backup_graph(req: BackupRequest, request: Request):
128
130
  require_admin(request)
129
- _require_service()
131
+ _require_service(request)
130
132
  return service.backup(req.path)
131
133
 
132
134
  @router.post("/api/knowledge-graph/restore")
133
135
  async def restore_graph(req: RestoreRequest, request: Request):
134
136
  require_admin(request)
135
- _require_service()
137
+ _require_service(request)
136
138
  try:
137
139
  return service.restore(req.path, verify=req.verify, dry_run=req.dry_run, confirm=req.confirm)
138
140
  except (ValueError, FileNotFoundError) as exc:
@@ -141,7 +143,7 @@ def create_portability_router(
141
143
  @router.post("/api/knowledge-graph/archive")
142
144
  async def encrypted_archive(req: EncryptedArchiveRequest, request: Request):
143
145
  require_admin(request)
144
- _require_service()
146
+ _require_service(request)
145
147
  try:
146
148
  return service.encrypted_archive(req.path, passphrase=req.passphrase)
147
149
  except (ValueError, FileNotFoundError) as exc:
@@ -150,7 +152,7 @@ def create_portability_router(
150
152
  @router.post("/api/knowledge-graph/archive/inspect")
151
153
  async def inspect_encrypted_archive(req: EncryptedInspectRequest, request: Request):
152
154
  require_admin(request)
153
- _require_service()
155
+ _require_service(request)
154
156
  try:
155
157
  return service.inspect_encrypted_archive(req.path, passphrase=req.passphrase)
156
158
  except (ValueError, FileNotFoundError) as exc:
@@ -159,16 +161,22 @@ def create_portability_router(
159
161
  @router.post("/api/knowledge-graph/archive/verify")
160
162
  async def verify_encrypted_archive(req: EncryptedVerifyRequest, request: Request):
161
163
  require_admin(request)
162
- _require_service()
164
+ _require_service(request)
163
165
  result = service.verify_encrypted_archive(req.path, passphrase=req.passphrase)
164
166
  if not result.get("ok"):
165
- raise HTTPException(status_code=400, detail="; ".join(result.get("errors") or ["Archive verification failed."]))
167
+ raise HTTPException(
168
+ status_code=400,
169
+ detail="; ".join(
170
+ result.get("errors")
171
+ or [translate("portability.verification_failed", resolve_language(request))]
172
+ ),
173
+ )
166
174
  return result
167
175
 
168
176
  @router.post("/api/knowledge-graph/archive/import")
169
177
  async def import_encrypted_archive(req: EncryptedRestoreRequest, request: Request):
170
178
  require_admin(request)
171
- _require_service()
179
+ _require_service(request)
172
180
  try:
173
181
  return service.import_encrypted_archive(
174
182
  req.path,
@@ -182,7 +190,7 @@ def create_portability_router(
182
190
  @router.post("/api/knowledge-graph/archive/restore")
183
191
  async def restore_encrypted_archive(req: EncryptedRestoreRequest, request: Request):
184
192
  require_admin(request)
185
- _require_service()
193
+ _require_service(request)
186
194
  try:
187
195
  return service.restore_encrypted_archive(
188
196
  req.path,
@@ -196,7 +204,7 @@ def create_portability_router(
196
204
  @router.post("/api/brain/storage/postgres/docker")
197
205
  async def setup_postgres_docker(req: DockerPostgresRequest, request: Request):
198
206
  require_admin(request)
199
- _require_service()
207
+ _require_service(request)
200
208
  return service.postgres_docker_setup(
201
209
  consent=req.consent,
202
210
  dry_run=req.dry_run,
@@ -206,7 +214,7 @@ def create_portability_router(
206
214
  @router.post("/api/brain/storage/migrate-postgres")
207
215
  async def migrate_sqlite_to_postgres(req: SQLiteToPostgresRequest, request: Request):
208
216
  require_admin(request)
209
- _require_service()
217
+ _require_service(request)
210
218
  try:
211
219
  return service.migrate_sqlite_to_postgres(
212
220
  dsn=req.dsn,
@@ -11,9 +11,11 @@ from __future__ import annotations
11
11
 
12
12
  from typing import Any, Callable, List, Optional
13
13
 
14
- from fastapi import APIRouter, HTTPException, Request
14
+ from fastapi import APIRouter, Request
15
15
  from pydantic import BaseModel, Field
16
16
 
17
+ from latticeai.core.messages import http_error, resolve_language
18
+
17
19
 
18
20
  class CreateProjectRequest(BaseModel):
19
21
  title: str = Field(..., min_length=1, max_length=200)
@@ -65,7 +67,7 @@ def create_project_sessions_router(
65
67
  user = require_user(request)
66
68
  record = store.get(session_id, user_email=user, workspace_id=_scope(request))
67
69
  if record is None:
68
- raise HTTPException(status_code=404, detail="project not found")
70
+ raise http_error(404, "project.not_found", resolve_language(request))
69
71
  return record
70
72
 
71
73
  @router.patch("/api/projects/{session_id}")
@@ -80,7 +82,7 @@ def create_project_sessions_router(
80
82
  workspace_id=_scope(request, write=True),
81
83
  )
82
84
  if record is None:
83
- raise HTTPException(status_code=404, detail="project not found")
85
+ raise http_error(404, "project.not_found", resolve_language(request))
84
86
  return record
85
87
 
86
88
  @router.put("/api/projects/{session_id}/todos")
@@ -93,7 +95,7 @@ def create_project_sessions_router(
93
95
  workspace_id=_scope(request, write=True),
94
96
  )
95
97
  if record is None:
96
- raise HTTPException(status_code=404, detail="project not found")
98
+ raise http_error(404, "project.not_found", resolve_language(request))
97
99
  return record
98
100
 
99
101
  @router.delete("/api/projects/{session_id}")
@@ -103,7 +105,7 @@ def create_project_sessions_router(
103
105
  session_id, user_email=user, workspace_id=_scope(request, write=True)
104
106
  )
105
107
  if not removed:
106
- raise HTTPException(status_code=404, detail="project not found")
108
+ raise http_error(404, "project.not_found", resolve_language(request))
107
109
  return {"status": "deleted", "id": session_id}
108
110
 
109
111
  return router
@@ -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.10.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.10.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}"