ltcai 10.7.0 → 10.8.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 (77) hide show
  1. package/README.md +52 -39
  2. package/docs/CHANGELOG.md +31 -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/core/file_generation.py +141 -8
  18. package/latticeai/core/legacy_compatibility.py +1 -1
  19. package/latticeai/core/marketplace.py +1 -1
  20. package/latticeai/core/messages.py +230 -0
  21. package/latticeai/core/workspace_computer_memory.py +84 -0
  22. package/latticeai/core/workspace_indexing.py +102 -0
  23. package/latticeai/core/workspace_onboarding.py +104 -0
  24. package/latticeai/core/workspace_os.py +24 -207
  25. package/latticeai/core/workspace_os_constants.py +1 -1
  26. package/latticeai/core/workspace_relationships.py +99 -0
  27. package/latticeai/services/architecture_readiness.py +1 -1
  28. package/latticeai/services/product_readiness.py +1 -1
  29. package/package.json +1 -1
  30. package/scripts/check_current_release_docs.mjs +1 -1
  31. package/scripts/check_screenshot_pixel_delta.py +92 -10
  32. package/scripts/release_screen_claims.json +37 -0
  33. package/src-tauri/Cargo.lock +1 -1
  34. package/src-tauri/Cargo.toml +1 -1
  35. package/src-tauri/tauri.conf.json +1 -1
  36. package/static/app/asset-manifest.json +37 -37
  37. package/static/app/assets/{Act-DIbkoJqs.js → Act-CbQNsFgc.js} +1 -1
  38. package/static/app/assets/{AdminConsole-Cbi6Jre6.js → AdminConsole-Bs8SR8fE.js} +1 -1
  39. package/static/app/assets/{Brain-BPfuXZ71.js → Brain-BZIPsZlH.js} +1 -1
  40. package/static/app/assets/{BrainHome-87XaAS4V.js → BrainHome-BaH5MDce.js} +1 -1
  41. package/static/app/assets/{BrainSignals-ExpcdiUi.js → BrainSignals-aDt-nSsW.js} +1 -1
  42. package/static/app/assets/{Capture-Bxe4WiVX.js → Capture-DDv940Bv.js} +1 -1
  43. package/static/app/assets/{CommandPalette-B1PV5Yd4.js → CommandPalette-Dq_MmPBl.js} +1 -1
  44. package/static/app/assets/{Library-BRlJJmwZ.js → Library-BkShbqmN.js} +1 -1
  45. package/static/app/assets/{LivingBrain-D61qG3WY.js → LivingBrain-CXb8094H.js} +1 -1
  46. package/static/app/assets/ProductFlow-BaiqFWLO.js +1 -0
  47. package/static/app/assets/{ReviewCard-BoiWv0Y1.js → ReviewCard-Be7E7X18.js} +1 -1
  48. package/static/app/assets/{System-Ci0HEtTJ.js → System-DCstbTxC.js} +1 -1
  49. package/static/app/assets/arrow-left-DC9Oquhs.js +1 -0
  50. package/static/app/assets/{bot-BaPt4PPn.js → bot-BR0eoeXY.js} +1 -1
  51. package/static/app/assets/{brain-BSkWaj3i.js → brain-BmWa1wea.js} +1 -1
  52. package/static/app/assets/{button-CKwtO1O0.js → button-U1nU7xX4.js} +1 -1
  53. package/static/app/assets/{circle-pause-CiUc5RRk.js → circle-pause-DG5KHjDL.js} +1 -1
  54. package/static/app/assets/{circle-play-BaTDxzR9.js → circle-play-Chhcuq90.js} +1 -1
  55. package/static/app/assets/{cpu-Dm1gHuP1.js → cpu-pkOSlT9h.js} +1 -1
  56. package/static/app/assets/{download-C32oGU-C.js → download-CSRwz923.js} +1 -1
  57. package/static/app/assets/{folder-open-Ch2jLVdD.js → folder-open-DiMJ7W_7.js} +1 -1
  58. package/static/app/assets/{hard-drive-BBjA5st8.js → hard-drive-iyLPAPdp.js} +1 -1
  59. package/static/app/assets/index-BZRvGOM2.css +2 -0
  60. package/static/app/assets/{index-J6h01X78.js → index-_ldE9TAX.js} +3 -3
  61. package/static/app/assets/{input-y6ABdKWY.js → input-e_r2hFAY.js} +1 -1
  62. package/static/app/assets/{permissionCopy-BEtc0Ihd.js → permissionCopy-9uOHktz3.js} +1 -1
  63. package/static/app/assets/{primitives-GSFXF2Pd.js → primitives-CnEAOmg2.js} +1 -1
  64. package/static/app/assets/search-veep4n6f.js +1 -0
  65. package/static/app/assets/{share-2-BgGq2A63.js → share-2-DLDJJKmI.js} +1 -1
  66. package/static/app/assets/{shield-alert-BYA491cj.js → shield-alert-BTtE-2EZ.js} +1 -1
  67. package/static/app/assets/{textarea-BAcim3fs.js → textarea-Bbzkcgqe.js} +1 -1
  68. package/static/app/assets/{useFocusTrap-qLg24jiv.js → useFocusTrap-DJzQd-Za.js} +1 -1
  69. package/static/app/assets/{useQuery-xkAu9eDX.js → useQuery-6R_tqHRe.js} +1 -1
  70. package/static/app/assets/{utils-CogdofAA.js → utils-XDsjcrhS.js} +1 -1
  71. package/static/app/assets/{workspace-BMvfx9_R.js → workspace-CwrG8LHq.js} +1 -1
  72. package/static/app/index.html +4 -4
  73. package/static/sw.js +1 -1
  74. package/static/app/assets/ProductFlow-BxjxI8Aa.js +0 -1
  75. package/static/app/assets/arrow-left-DArdBUxd.js +0 -1
  76. package/static/app/assets/index-BV7Yf6Eq.css +0 -2
  77. package/static/app/assets/search-CaIZKyo_.js +0 -1
@@ -9,10 +9,11 @@ from dataclasses import dataclass
9
9
  from typing import Any, Awaitable, Callable, Dict, Optional
10
10
  from urllib.parse import urlencode
11
11
 
12
- from fastapi import APIRouter, HTTPException, Request
12
+ from fastapi import APIRouter, Request
13
13
  from fastapi.responses import JSONResponse, RedirectResponse
14
14
  from pydantic import BaseModel
15
15
 
16
+ from latticeai.core.messages import DEFAULT_LANGUAGE, http_error, resolve_language
16
17
  from latticeai.core.oidc import (
17
18
  OIDCValidationError,
18
19
  )
@@ -92,18 +93,16 @@ def create_auth_router(
92
93
  ) -> APIRouter:
93
94
  router = APIRouter()
94
95
 
95
- def _enforce_password_policy(password: str) -> None:
96
+ def _enforce_password_policy(password: str, language: str = DEFAULT_LANGUAGE) -> None:
96
97
  # Real policy (v4): length >= 8 with letters AND digits. A 4-char
97
98
  # minimum was not a policy.
98
99
  pw = str(password or "")
99
100
  if len(pw) < 8 or not any(c.isalpha() for c in pw) or not any(c.isdigit() for c in pw):
100
- raise HTTPException(
101
- status_code=400,
102
- detail="비밀번호는 8자 이상이며 영문자와 숫자를 모두 포함해야 합니다.",
103
- )
101
+ raise http_error(400, "auth.password_too_weak", language)
104
102
 
105
103
  @router.post("/register")
106
104
  async def register(req: UserRegister, request: Request):
105
+ lang = resolve_language(request)
107
106
  check_ip_rate_limit(client_ip(request), "register", max_calls=5, window_secs=3600)
108
107
  invite_claim = bool(
109
108
  invite_gate_enabled
@@ -111,19 +110,16 @@ def create_auth_router(
111
110
  and invite_authorized(request)
112
111
  )
113
112
  if invite_gate_enabled and not invite_claim:
114
- raise HTTPException(
115
- status_code=403,
116
- detail="유효한 서명 초대 권한이 필요합니다.",
117
- )
113
+ raise http_error(403, "auth.invitation_required", lang)
118
114
  # Closed public registration stays closed unless the operator enabled
119
115
  # the invite gate and this exact request carries a valid signed claim.
120
116
  if not open_registration and not invite_claim:
121
- raise HTTPException(status_code=403, detail="회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.")
122
- _enforce_password_policy(req.password)
117
+ raise http_error(403, "auth.registration_disabled", lang)
118
+ _enforce_password_policy(req.password, lang)
123
119
  email = normalize_email(req.email)
124
120
  users = load_users()
125
121
  if email in users:
126
- raise HTTPException(status_code=400, detail="이미 존재하는 이메일입니다.")
122
+ raise http_error(400, "auth.email_taken", lang)
127
123
  role = "admin" if not users else "user"
128
124
  users[email] = {
129
125
  "password": hash_password(req.password),
@@ -140,14 +136,15 @@ def create_auth_router(
140
136
 
141
137
  @router.post("/login")
142
138
  async def login(req: UserLogin, request: Request):
139
+ lang = resolve_language(request)
143
140
  check_ip_rate_limit(client_ip(request), "login", max_calls=10, window_secs=300)
144
141
  email = normalize_email(req.email)
145
142
  users = load_users()
146
143
  user = users.get(email)
147
144
  if not user or not verify_and_migrate(email, req.password, user.get("password", ""), users):
148
- raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 틀렸습니다.")
145
+ raise http_error(401, "auth.bad_credentials", lang)
149
146
  if user.get("disabled"):
150
- raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
147
+ raise http_error(403, "auth.account_disabled", lang)
151
148
  role = get_user_role(email, users)
152
149
  token = create_session(email)
153
150
  response = JSONResponse(content={
@@ -174,10 +171,11 @@ def create_auth_router(
174
171
 
175
172
  @router.get("/auth/sso/login")
176
173
  async def sso_login(request: Request):
174
+ lang = resolve_language(request)
177
175
  settings = get_sso_settings()
178
176
  discovery = await get_sso_discovery()
179
177
  if not settings.get("enabled") or not discovery:
180
- raise HTTPException(status_code=503, detail="SSO가 설정되지 않았습니다.")
178
+ raise http_error(503, "sso.not_configured", lang)
181
179
  state = secrets.token_urlsafe(16)
182
180
  nonce = secrets.token_urlsafe(16)
183
181
  # PKCE (S256): bind the token exchange to this login, so an
@@ -214,16 +212,17 @@ def create_auth_router(
214
212
  return RedirectResponse(f"{discovery['authorization_endpoint']}?{params}")
215
213
 
216
214
  @router.get("/auth/sso/callback")
217
- async def sso_callback(code: str = "", state: str = "", error: str = ""):
215
+ async def sso_callback(request: Request, code: str = "", state: str = "", error: str = ""):
216
+ lang = resolve_language(request)
218
217
  entry = _sso_states.pop(state, None)
219
218
  if entry is None or time.time() - entry.issued_at > 300:
220
- raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
219
+ raise http_error(400, "sso.invalid_state", lang)
221
220
  if error:
222
221
  return RedirectResponse(f"/?{urlencode({'sso_error': error})}")
223
222
  settings = get_sso_settings()
224
223
  discovery = await get_sso_discovery()
225
224
  if not settings.get("enabled") or not discovery:
226
- raise HTTPException(status_code=503, detail="SSO 설정 오류입니다.")
225
+ raise http_error(503, "sso.config_error", lang)
227
226
  import httpx as _httpx
228
227
  async with _httpx.AsyncClient() as c:
229
228
  r = await c.post(discovery["token_endpoint"], data={
@@ -237,7 +236,7 @@ def create_auth_router(
237
236
  tokens = r.json()
238
237
  id_token = tokens.get("id_token")
239
238
  if not id_token:
240
- raise HTTPException(status_code=400, detail="ID 토큰을 받지 못했습니다.")
239
+ raise http_error(400, "sso.no_id_token", lang)
241
240
  # Never trust a decoded JWT payload: verify signature (against the
242
241
  # provider JWKS), issuer, audience, expiry and the login nonce before
243
242
  # using any claim. Any failure is fail-closed (401).
@@ -253,20 +252,17 @@ def create_auth_router(
253
252
  )
254
253
  except OIDCValidationError as exc:
255
254
  logging.warning("SSO ID token rejected: %s", exc)
256
- raise HTTPException(status_code=401, detail="SSO 토큰 검증에 실패했습니다.")
255
+ raise http_error(401, "sso.token_verification_failed", lang)
257
256
  except Exception as exc: # discovery/JWKS fetch failure → fail closed
258
257
  logging.warning("SSO token validation error: %s", exc)
259
- raise HTTPException(status_code=502, detail="SSO 공급자 검증에 실패했습니다.")
258
+ raise http_error(502, "sso.provider_verification_failed", lang)
260
259
  email = normalize_email(payload.get("email") or payload.get("preferred_username") or payload.get("upn") or "")
261
260
  if not email:
262
- raise HTTPException(status_code=400, detail="이메일을 확인할 수 없습니다.")
261
+ raise http_error(400, "sso.email_unavailable", lang)
263
262
  users = load_users()
264
263
  if email not in users:
265
264
  if invite_gate_enabled and not entry.invite_authorized:
266
- raise HTTPException(
267
- status_code=403,
268
- detail="신규 SSO 계정에는 유효한 서명 초대 권한이 필요합니다.",
269
- )
265
+ raise http_error(403, "sso.invitation_required", lang)
270
266
  is_first = len(users) == 0
271
267
  users[email] = {
272
268
  "password": "",
@@ -280,7 +276,7 @@ def create_auth_router(
280
276
  ensure_identity(email, users[email])
281
277
  save_users(users)
282
278
  if users[email].get("disabled"):
283
- raise HTTPException(status_code=403, detail="비활성화된 계정입니다.")
279
+ raise http_error(403, "auth.account_disabled", lang)
284
280
  token = create_session(email)
285
281
  resp = RedirectResponse("/app", status_code=302)
286
282
  resp.set_cookie(
@@ -309,33 +305,35 @@ def create_auth_router(
309
305
 
310
306
  @router.post("/account/change-password")
311
307
  async def change_password(req: ChangePasswordRequest, request: Request):
308
+ lang = resolve_language(request)
312
309
  email = normalize_email(require_user(request))
313
310
  if not email:
314
- raise HTTPException(status_code=401, detail="인증이 필요합니다.")
315
- _enforce_password_policy(req.new_password)
311
+ raise http_error(401, "auth.login_required", lang)
312
+ _enforce_password_policy(req.new_password, lang)
316
313
  users = load_users()
317
314
  user = users.get(email)
318
315
  if not user:
319
- raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
316
+ raise http_error(404, "auth.user_not_found", lang)
320
317
  if not verify_and_migrate(email, req.current_password, user.get("password", ""), users):
321
- raise HTTPException(status_code=401, detail="현재 비밀번호가 틀렸습니다.")
318
+ raise http_error(401, "auth.current_password_wrong", lang)
322
319
  users[email]["password"] = hash_password(req.new_password)
323
320
  save_users(users)
324
321
  return {"status": "ok", "message": "비밀번호가 변경되었습니다."}
325
322
 
326
323
  @router.patch("/account/profile")
327
324
  async def update_profile(req: UpdateProfileRequest, request: Request):
325
+ lang = resolve_language(request)
328
326
  email = normalize_email(require_user(request))
329
327
  if not email:
330
- raise HTTPException(status_code=401, detail="인증이 필요합니다.")
328
+ raise http_error(401, "auth.login_required", lang)
331
329
  if req.name is not None and not req.name.strip():
332
- raise HTTPException(status_code=400, detail="이름을 입력해주세요.")
330
+ raise http_error(400, "auth.name_required", lang)
333
331
  if req.nickname is not None and not req.nickname.strip():
334
- raise HTTPException(status_code=400, detail="닉네임을 입력해주세요.")
332
+ raise http_error(400, "auth.nickname_required", lang)
335
333
  users = load_users()
336
334
  user = users.get(email)
337
335
  if not user:
338
- raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
336
+ raise http_error(404, "auth.user_not_found", lang)
339
337
  if req.name is not None:
340
338
  users[email]["name"] = req.name.strip()
341
339
  if req.nickname is not None:
@@ -345,15 +343,16 @@ def create_auth_router(
345
343
 
346
344
  @router.get("/account/profile")
347
345
  async def get_profile(request: Request):
346
+ lang = resolve_language(request)
348
347
  email = normalize_email(require_user(request))
349
348
  if not email:
350
349
  if require_auth:
351
- raise HTTPException(status_code=401, detail="인증이 필요합니다.")
350
+ raise http_error(401, "auth.login_required", lang)
352
351
  return {"email": "", "name": "Local User", "nickname": "You", "role": "admin", "is_admin": True}
353
352
  users = load_users()
354
353
  user = users.get(email)
355
354
  if not user:
356
- raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
355
+ raise http_error(404, "auth.user_not_found", lang)
357
356
  role = get_user_role(email, users)
358
357
  return {"email": email, "name": user.get("name", ""), "nickname": user.get("nickname", ""),
359
358
  "role": role, "is_admin": role == "admin"}
@@ -29,6 +29,7 @@ from pydantic import BaseModel
29
29
  from lattice_brain.ingestion import IngestionItem, capture_quality_verdict
30
30
  from latticeai import __version__
31
31
  from latticeai.api.workspace_scope import resolve_workspace_scope
32
+ from latticeai.core.messages import DEFAULT_LANGUAGE, http_error, resolve_language
32
33
  from latticeai.core.quiet import quiet
33
34
 
34
35
  MAX_TAB_BYTES = 4 * 1024 * 1024 # 4 MB per captured tab payload
@@ -395,9 +396,9 @@ def create_browser_router(
395
396
  router = APIRouter()
396
397
  _fetch = fetch_url or _default_fetch_url
397
398
 
398
- def _require_pipeline():
399
+ def _require_pipeline(language: str = DEFAULT_LANGUAGE):
399
400
  if pipeline is None or not pipeline.available():
400
- raise HTTPException(status_code=503, detail="Knowledge Graph ingestion is disabled.")
401
+ raise http_error(503, "capture.ingestion_disabled", language)
401
402
 
402
403
  def _write_workspace(request: Request, body_workspace: Optional[str], user: str) -> Optional[str]:
403
404
  return resolve_workspace_scope(
@@ -412,7 +413,7 @@ def create_browser_router(
412
413
  async def read_url(req: ReadUrlRequest, request: Request):
413
414
  """Fetch a public URL locally and ingest it as a web_url source."""
414
415
  user = require_user(request)
415
- _require_pipeline()
416
+ _require_pipeline(resolve_language(request))
416
417
  workspace_id = _write_workspace(request, req.workspace_id, user)
417
418
  url = _validate_http_url(req.url)
418
419
  try:
@@ -443,7 +444,7 @@ def create_browser_router(
443
444
  async def ingest_current_tab(req: IngestTabRequest, request: Request):
444
445
  """Ingest a payload captured from the local browser extension."""
445
446
  user = require_user(request)
446
- _require_pipeline()
447
+ _require_pipeline(resolve_language(request))
447
448
  workspace_id = _write_workspace(request, req.workspace_id, user)
448
449
  url = _validate_http_url(req.url)
449
450
  # Bound the entire capture, not merely each field independently. The
@@ -462,14 +463,14 @@ def create_browser_router(
462
463
  if value
463
464
  )
464
465
  if captured_bytes > max_tab_bytes:
465
- raise HTTPException(status_code=413, detail="Captured payload is too large.")
466
+ raise http_error(413, "capture.payload_too_large", resolve_language(request))
466
467
  text = (req.text or "").strip()
467
468
  if not text and req.html:
468
469
  _title, text = extract_readable_text(req.html)
469
470
  if not text:
470
471
  text = (req.selected_text or "").strip()
471
472
  if not text:
472
- raise HTTPException(status_code=400, detail="No text, html, or selected_text provided.")
473
+ raise http_error(400, "capture.nothing_to_capture", resolve_language(request))
473
474
  res = pipeline.ingest(
474
475
  IngestionItem(
475
476
  source_type="browser_tab",
@@ -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.8.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.8.0"
14
14
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
15
15
 
16
16