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.
- package/README.md +55 -40
- package/docs/CHANGELOG.md +68 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/retrieval_vector.py +69 -33
- package/lattice_brain/runtime/multi_agent.py +96 -15
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +9 -6
- package/latticeai/api/auth.py +37 -38
- package/latticeai/api/browser.py +7 -6
- package/latticeai/api/chat.py +9 -6
- package/latticeai/api/chat_agent_http.py +2 -1
- package/latticeai/api/chat_history.py +4 -2
- package/latticeai/api/chat_intents.py +14 -14
- package/latticeai/api/knowledge_graph.py +5 -4
- package/latticeai/api/local_files.py +25 -15
- package/latticeai/api/mcp.py +15 -11
- package/latticeai/api/memory.py +2 -1
- package/latticeai/api/models.py +39 -24
- package/latticeai/api/network_boundary.py +9 -4
- package/latticeai/api/portability.py +27 -19
- package/latticeai/api/project_sessions.py +7 -5
- package/latticeai/api/review_queue.py +12 -7
- package/latticeai/api/setup.py +10 -11
- package/latticeai/api/static_routes.py +62 -46
- package/latticeai/api/tools.py +7 -6
- package/latticeai/core/file_generation.py +141 -8
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +20 -4
- package/latticeai/core/messages.py +452 -0
- package/latticeai/core/workspace_computer_memory.py +84 -0
- package/latticeai/core/workspace_indexing.py +102 -0
- package/latticeai/core/workspace_onboarding.py +104 -0
- package/latticeai/core/workspace_os.py +24 -207
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_relationships.py +99 -0
- package/latticeai/integrations/telegram_bot.py +20 -18
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/model_loading.py +63 -27
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +3 -2
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_screenshot_pixel_delta.py +92 -10
- package/scripts/check_server_i18n.mjs +96 -0
- package/scripts/release_screen_claims.json +46 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-DIbkoJqs.js → Act-CS9IeqUX.js} +1 -1
- package/static/app/assets/{AdminConsole-Cbi6Jre6.js → AdminConsole-3UkIEWGA.js} +1 -1
- package/static/app/assets/{Brain-BPfuXZ71.js → Brain-B22EmNqS.js} +1 -1
- package/static/app/assets/{BrainHome-87XaAS4V.js → BrainHome-CMDqgJF4.js} +2 -2
- package/static/app/assets/{BrainSignals-ExpcdiUi.js → BrainSignals-BeE8RJo3.js} +1 -1
- package/static/app/assets/{Capture-Bxe4WiVX.js → Capture-ZX9bQh68.js} +1 -1
- package/static/app/assets/{CommandPalette-B1PV5Yd4.js → CommandPalette-86m4FCcN.js} +1 -1
- package/static/app/assets/{Library-BRlJJmwZ.js → Library-Bhz5LUca.js} +1 -1
- package/static/app/assets/{LivingBrain-D61qG3WY.js → LivingBrain-BSa0wpFG.js} +1 -1
- package/static/app/assets/ProductFlow-IP4Q-5aQ.js +1 -0
- package/static/app/assets/{ReviewCard-BoiWv0Y1.js → ReviewCard-BepjSDpN.js} +1 -1
- package/static/app/assets/{System-Ci0HEtTJ.js → System-Bwx1h_jT.js} +1 -1
- package/static/app/assets/arrow-left-ig7AZU8B.js +1 -0
- package/static/app/assets/{bot-BaPt4PPn.js → bot-DQj0-LkM.js} +1 -1
- package/static/app/assets/{brain-BSkWaj3i.js → brain-D8OEwmVj.js} +1 -1
- package/static/app/assets/{button-CKwtO1O0.js → button-CmTknyAP.js} +1 -1
- package/static/app/assets/{circle-pause-CiUc5RRk.js → circle-pause-yTCWRziJ.js} +1 -1
- package/static/app/assets/{circle-play-BaTDxzR9.js → circle-play-Ccrva84R.js} +1 -1
- package/static/app/assets/{cpu-Dm1gHuP1.js → cpu-CbJqWTlS.js} +1 -1
- package/static/app/assets/{download-C32oGU-C.js → download-CkSzbzU-.js} +1 -1
- package/static/app/assets/{folder-open-Ch2jLVdD.js → folder-open-CKyjQ4PU.js} +1 -1
- package/static/app/assets/{hard-drive-BBjA5st8.js → hard-drive-DAzk9um0.js} +1 -1
- package/static/app/assets/index-BfD-jhA9.css +2 -0
- package/static/app/assets/{index-J6h01X78.js → index-CxOcwsHV.js} +3 -3
- package/static/app/assets/{input-y6ABdKWY.js → input-DcMETmZ7.js} +1 -1
- package/static/app/assets/{permissionCopy-BEtc0Ihd.js → permissionCopy-BVf13_25.js} +1 -1
- package/static/app/assets/{primitives-GSFXF2Pd.js → primitives-CQV9Q2YM.js} +1 -1
- package/static/app/assets/search-CXGASMMH.js +1 -0
- package/static/app/assets/{share-2-BgGq2A63.js → share-2-COWCHNZm.js} +1 -1
- package/static/app/assets/{shield-alert-BYA491cj.js → shield-alert-BDrvilyK.js} +1 -1
- package/static/app/assets/{textarea-BAcim3fs.js → textarea-rUmsc8cP.js} +1 -1
- package/static/app/assets/{useFocusTrap-qLg24jiv.js → useFocusTrap-Bi5UY_8v.js} +1 -1
- package/static/app/assets/{useQuery-xkAu9eDX.js → useQuery-C-AicB-3.js} +1 -1
- package/static/app/assets/{utils-CogdofAA.js → utils-BMwWg78e.js} +1 -1
- package/static/app/assets/{workspace-BMvfx9_R.js → workspace-Y93tls8P.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/ProductFlow-BxjxI8Aa.js +0 -1
- package/static/app/assets/arrow-left-DArdBUxd.js +0 -1
- package/static/app/assets/index-BV7Yf6Eq.css +0 -2
- package/static/app/assets/search-CaIZKyo_.js +0 -1
package/latticeai/api/setup.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
package/latticeai/api/tools.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
879
|
-
|
|
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
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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",
|
|
@@ -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.
|
|
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 =
|
|
461
|
+
completed = await asyncio.to_thread(
|
|
462
|
+
_run_installer,
|
|
451
463
|
[sys.executable, "-m", "pip", "install", "--upgrade", pkg],
|
|
452
|
-
|
|
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 =
|
|
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 =
|
|
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}"
|