ltcai 10.7.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 (97) hide show
  1. package/README.md +55 -40
  2. package/docs/CHANGELOG.md +68 -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/graph/retrieval_vector.py +69 -33
  12. package/lattice_brain/runtime/multi_agent.py +96 -15
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +9 -6
  15. package/latticeai/api/auth.py +37 -38
  16. package/latticeai/api/browser.py +7 -6
  17. package/latticeai/api/chat.py +9 -6
  18. package/latticeai/api/chat_agent_http.py +2 -1
  19. package/latticeai/api/chat_history.py +4 -2
  20. package/latticeai/api/chat_intents.py +14 -14
  21. package/latticeai/api/knowledge_graph.py +5 -4
  22. package/latticeai/api/local_files.py +25 -15
  23. package/latticeai/api/mcp.py +15 -11
  24. package/latticeai/api/memory.py +2 -1
  25. package/latticeai/api/models.py +39 -24
  26. package/latticeai/api/network_boundary.py +9 -4
  27. package/latticeai/api/portability.py +27 -19
  28. package/latticeai/api/project_sessions.py +7 -5
  29. package/latticeai/api/review_queue.py +12 -7
  30. package/latticeai/api/setup.py +10 -11
  31. package/latticeai/api/static_routes.py +62 -46
  32. package/latticeai/api/tools.py +7 -6
  33. package/latticeai/core/file_generation.py +141 -8
  34. package/latticeai/core/legacy_compatibility.py +1 -1
  35. package/latticeai/core/marketplace.py +1 -1
  36. package/latticeai/core/mcp_registry.py +20 -4
  37. package/latticeai/core/messages.py +452 -0
  38. package/latticeai/core/workspace_computer_memory.py +84 -0
  39. package/latticeai/core/workspace_indexing.py +102 -0
  40. package/latticeai/core/workspace_onboarding.py +104 -0
  41. package/latticeai/core/workspace_os.py +24 -207
  42. package/latticeai/core/workspace_os_constants.py +1 -1
  43. package/latticeai/core/workspace_relationships.py +99 -0
  44. package/latticeai/integrations/telegram_bot.py +20 -18
  45. package/latticeai/services/architecture_readiness.py +1 -1
  46. package/latticeai/services/model_loading.py +63 -27
  47. package/latticeai/services/product_readiness.py +1 -1
  48. package/package.json +3 -2
  49. package/scripts/check_current_release_docs.mjs +1 -1
  50. package/scripts/check_screenshot_pixel_delta.py +92 -10
  51. package/scripts/check_server_i18n.mjs +96 -0
  52. package/scripts/release_screen_claims.json +46 -0
  53. package/src-tauri/Cargo.lock +1 -1
  54. package/src-tauri/Cargo.toml +1 -1
  55. package/src-tauri/tauri.conf.json +1 -1
  56. package/static/app/asset-manifest.json +37 -37
  57. package/static/app/assets/{Act-DIbkoJqs.js → Act-CS9IeqUX.js} +1 -1
  58. package/static/app/assets/{AdminConsole-Cbi6Jre6.js → AdminConsole-3UkIEWGA.js} +1 -1
  59. package/static/app/assets/{Brain-BPfuXZ71.js → Brain-B22EmNqS.js} +1 -1
  60. package/static/app/assets/{BrainHome-87XaAS4V.js → BrainHome-CMDqgJF4.js} +2 -2
  61. package/static/app/assets/{BrainSignals-ExpcdiUi.js → BrainSignals-BeE8RJo3.js} +1 -1
  62. package/static/app/assets/{Capture-Bxe4WiVX.js → Capture-ZX9bQh68.js} +1 -1
  63. package/static/app/assets/{CommandPalette-B1PV5Yd4.js → CommandPalette-86m4FCcN.js} +1 -1
  64. package/static/app/assets/{Library-BRlJJmwZ.js → Library-Bhz5LUca.js} +1 -1
  65. package/static/app/assets/{LivingBrain-D61qG3WY.js → LivingBrain-BSa0wpFG.js} +1 -1
  66. package/static/app/assets/ProductFlow-IP4Q-5aQ.js +1 -0
  67. package/static/app/assets/{ReviewCard-BoiWv0Y1.js → ReviewCard-BepjSDpN.js} +1 -1
  68. package/static/app/assets/{System-Ci0HEtTJ.js → System-Bwx1h_jT.js} +1 -1
  69. package/static/app/assets/arrow-left-ig7AZU8B.js +1 -0
  70. package/static/app/assets/{bot-BaPt4PPn.js → bot-DQj0-LkM.js} +1 -1
  71. package/static/app/assets/{brain-BSkWaj3i.js → brain-D8OEwmVj.js} +1 -1
  72. package/static/app/assets/{button-CKwtO1O0.js → button-CmTknyAP.js} +1 -1
  73. package/static/app/assets/{circle-pause-CiUc5RRk.js → circle-pause-yTCWRziJ.js} +1 -1
  74. package/static/app/assets/{circle-play-BaTDxzR9.js → circle-play-Ccrva84R.js} +1 -1
  75. package/static/app/assets/{cpu-Dm1gHuP1.js → cpu-CbJqWTlS.js} +1 -1
  76. package/static/app/assets/{download-C32oGU-C.js → download-CkSzbzU-.js} +1 -1
  77. package/static/app/assets/{folder-open-Ch2jLVdD.js → folder-open-CKyjQ4PU.js} +1 -1
  78. package/static/app/assets/{hard-drive-BBjA5st8.js → hard-drive-DAzk9um0.js} +1 -1
  79. package/static/app/assets/index-BfD-jhA9.css +2 -0
  80. package/static/app/assets/{index-J6h01X78.js → index-CxOcwsHV.js} +3 -3
  81. package/static/app/assets/{input-y6ABdKWY.js → input-DcMETmZ7.js} +1 -1
  82. package/static/app/assets/{permissionCopy-BEtc0Ihd.js → permissionCopy-BVf13_25.js} +1 -1
  83. package/static/app/assets/{primitives-GSFXF2Pd.js → primitives-CQV9Q2YM.js} +1 -1
  84. package/static/app/assets/search-CXGASMMH.js +1 -0
  85. package/static/app/assets/{share-2-BgGq2A63.js → share-2-COWCHNZm.js} +1 -1
  86. package/static/app/assets/{shield-alert-BYA491cj.js → shield-alert-BDrvilyK.js} +1 -1
  87. package/static/app/assets/{textarea-BAcim3fs.js → textarea-rUmsc8cP.js} +1 -1
  88. package/static/app/assets/{useFocusTrap-qLg24jiv.js → useFocusTrap-Bi5UY_8v.js} +1 -1
  89. package/static/app/assets/{useQuery-xkAu9eDX.js → useQuery-C-AicB-3.js} +1 -1
  90. package/static/app/assets/{utils-CogdofAA.js → utils-BMwWg78e.js} +1 -1
  91. package/static/app/assets/{workspace-BMvfx9_R.js → workspace-Y93tls8P.js} +1 -1
  92. package/static/app/index.html +4 -4
  93. package/static/sw.js +1 -1
  94. package/static/app/assets/ProductFlow-BxjxI8Aa.js +0 -1
  95. package/static/app/assets/arrow-left-DArdBUxd.js +0 -1
  96. package/static/app/assets/index-BV7Yf6Eq.css +0 -2
  97. package/static/app/assets/search-CaIZKyo_.js +0 -1
@@ -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:
@@ -312,9 +312,54 @@ def validate_file_content(content: str, target_path: str) -> Tuple[bool, str]:
312
312
  if "```" in content:
313
313
  return False, "output still contains Markdown fences"
314
314
  return True, "ok"
315
+ # Prose types (.md, .txt, .csv, …) have no grammar to check, which used to
316
+ # mean *nothing* was checked: a 1–4B model that answered "Sure! Here is the
317
+ # document you asked for:" and stopped had its sentence saved as the file,
318
+ # because the fence stripper only removes conversational lines it can
319
+ # recognise and the length guard on `looks_like_refusal` lets a wordy
320
+ # refusal through. The two checks below are the only ones that generalise
321
+ # without inventing a grammar: it must not still be wearing fences, and it
322
+ # must not be *only* an answer about the file.
323
+ if "```" in content:
324
+ return False, "output still contains Markdown fences"
325
+ if _looks_like_commentary(content):
326
+ return False, "the reply talks about the file instead of being the file"
315
327
  return True, "ok"
316
328
 
317
329
 
330
+ # Openers that mean "I am about to give you the thing" — if the whole reply is
331
+ # one of these, the thing never arrived.
332
+ _COMMENTARY_RE = re.compile(
333
+ r"^\s*("
334
+ r"(sure|of course|certainly|okay|ok|alright|here|below|the following)\b"
335
+ r"|i('ve| have| will|'ll)\b"
336
+ r"|(물론|네[,!. ]|알겠|다음은|아래(는|의)?|요청하신|원하시는)"
337
+ r")",
338
+ re.IGNORECASE,
339
+ )
340
+
341
+
342
+ def _looks_like_commentary(content: str) -> bool:
343
+ """True when the reply reads as an answer *about* a file, not the file.
344
+
345
+ Deliberately conservative — a long document that merely opens with "The
346
+ following" is a document. Only a short reply that both opens
347
+ conversationally and never grows into content is rejected, so a real file
348
+ is never thrown away to catch a chat line.
349
+ """
350
+ stripped = content.strip()
351
+ if len(stripped) > 400:
352
+ return False
353
+ if not _COMMENTARY_RE.match(stripped):
354
+ return False
355
+ # Structure means content arrived after the preamble: a heading, a list, a
356
+ # table row, a delimiter, or simply several lines of body text.
357
+ body = stripped.split("\n", 1)[1].strip() if "\n" in stripped else ""
358
+ if re.search(r"^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|\||>)", body, re.MULTILINE):
359
+ return False
360
+ return len(body) < 120
361
+
362
+
318
363
  # ── prompting ───────────────────────────────────────────────────────────
319
364
 
320
365
  _FIRST_LINE_HINTS: Dict[str, str] = {
@@ -869,14 +914,28 @@ async def generate_file_content(
869
914
  """Generate validated file content with any LLM.
870
915
 
871
916
  ``generate`` is an async callable ``context -> raw model text``. Runs up
872
- to ``max_attempts`` model calls (the second with corrective feedback),
917
+ to ``max_attempts`` model calls (each retry carrying corrective feedback),
873
918
  then falls back to deterministic repair, so the returned content is
874
919
  always non-empty and structurally valid for the target type.
920
+
921
+ One extra call beyond ``max_attempts`` is spent — at most once per
922
+ request — when the model has returned a byte-identical rejected reply.
923
+ That is the one case where the ordinary retry is known to be dead on
924
+ arrival: the corrective feedback did not change the reply, so the budget
925
+ is better spent on a prompt that names the repetition than on a third
926
+ identical round trip. Small local models hit this constantly; large ones
927
+ never do, so the extra call is not charged to models that do not need it.
875
928
  """
876
929
  attempts: List[Dict[str, Any]] = []
877
930
  feedback: Optional[str] = None
878
- last_candidate = ""
879
- for attempt in range(1, max_attempts + 1):
931
+ best_candidate = ""
932
+ best_score = (-1, -1)
933
+ seen: set[str] = set()
934
+ escalations_left = 1
935
+ attempt = 0
936
+ budget = max_attempts
937
+ while attempt < budget:
938
+ attempt += 1
880
939
  context = build_file_generation_context(
881
940
  target_path, user_request, feedback=feedback, bundle_files=bundle_files,
882
941
  )
@@ -888,16 +947,90 @@ async def generate_file_content(
888
947
  continue
889
948
  candidate = extract_file_content(str(raw or ""), target_path)
890
949
  ok, reason = validate_file_content(candidate, target_path)
891
- attempts.append({"attempt": attempt, "valid": ok, "reason": reason})
950
+ record: Dict[str, Any] = {"attempt": attempt, "valid": ok, "reason": reason}
892
951
  if ok:
952
+ attempts.append(record)
893
953
  return candidate, {"attempts": attempts, "repaired": False}
894
- if len(candidate) > len(last_candidate):
895
- last_candidate = candidate
896
- feedback = reason
897
- repaired = repair_file_content(last_candidate, target_path, user_request)
954
+
955
+ # A small model handed the same corrective feedback often replays the
956
+ # same reply verbatim. Saying "you sent this before" is the only signal
957
+ # left that has any chance of moving it, and it makes the wasted retry
958
+ # visible in the trace instead of looking like two genuine tries.
959
+ fingerprint = candidate.strip()
960
+ repeated = fingerprint in seen and bool(fingerprint)
961
+ record["repeated"] = repeated
962
+ seen.add(fingerprint)
963
+ if repeated and escalations_left and attempt >= budget:
964
+ # The retry budget is exhausted and the last thing it bought was a
965
+ # duplicate. Buy one more, but only with a prompt that says so.
966
+ escalations_left -= 1
967
+ budget += 1
968
+ record["escalated"] = True
969
+ attempts.append(record)
970
+
971
+ # Keep the candidate that is *closest to a file*, not the longest one.
972
+ # Longest-wins handed repair a 900-character apology in preference to a
973
+ # 300-character HTML document that only needed its </html> closing —
974
+ # and repair can finish the document but can only bury the apology.
975
+ score = _salvage_score(candidate, target_path)
976
+ if score > best_score:
977
+ best_score, best_candidate = score, candidate
978
+
979
+ feedback = (
980
+ f"{reason}. You already sent exactly this reply and it was rejected "
981
+ "for the same reason — do not repeat it. Output the file itself, "
982
+ "starting at its first character."
983
+ if repeated else reason
984
+ )
985
+ repaired = repair_file_content(best_candidate, target_path, user_request)
898
986
  return repaired, {"attempts": attempts, "repaired": True}
899
987
 
900
988
 
989
+ def _salvage_score(candidate: str, target_path: str) -> Tuple[int, int]:
990
+ """How useful an invalid candidate is as raw material for repair.
991
+
992
+ ``(tier, length)`` — tier first, so a short real document always beats a
993
+ long non-document; length breaks ties within a tier.
994
+
995
+ Tier 2 something of the right shape that repair can finish (an HTML
996
+ document missing its close tag, parseable-ish JSON, Python that
997
+ at least tokenises).
998
+ Tier 1 ordinary text: no structure, but the words may be the content.
999
+ Tier 0 a refusal — repair should prefer literally anything else, because
1000
+ an apology written into the file is worse than an empty stub.
1001
+ """
1002
+ text = candidate.strip()
1003
+ if not text:
1004
+ return (0, 0)
1005
+ if looks_like_refusal(text):
1006
+ return (0, len(text))
1007
+
1008
+ ext = _ext(target_path)
1009
+ lower = text.lower()
1010
+ if ext in (".html", ".htm"):
1011
+ if lower.startswith("<!doctype") or lower.startswith("<html"):
1012
+ return (2, len(text))
1013
+ elif ext == ".json":
1014
+ if _slice_json_document(text) is not None:
1015
+ return (2, len(text))
1016
+ elif ext == ".py":
1017
+ try:
1018
+ ast.parse(text)
1019
+ except SyntaxError:
1020
+ pass
1021
+ else:
1022
+ return (2, len(text))
1023
+ elif ext in _BRACED_CODE_EXTENSIONS:
1024
+ if _check_balanced_delimiters(text)[0]:
1025
+ return (2, len(text))
1026
+ elif ext in _COMPONENT_EXTENSIONS:
1027
+ if _check_component_blocks(text)[0]:
1028
+ return (2, len(text))
1029
+ elif ext == ".css" and "{" in text and "}" in text:
1030
+ return (2, len(text))
1031
+ return (1, len(text))
1032
+
1033
+
901
1034
  __all__ = [
902
1035
  "PREVIEWABLE_EXTENSIONS",
903
1036
  "build_file_generation_context",
@@ -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.7.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.7.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}"