ltcai 11.5.0 → 11.5.2

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 (182) hide show
  1. package/README.md +86 -47
  2. package/docs/CHANGELOG.md +76 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/HYBRID_CLOUD_KG_STREAMING.md +8 -4
  6. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/WORKFLOW_DESIGNER.md +1 -1
  12. package/docs/architecture.md +4 -4
  13. package/docs/kg-schema.md +1 -1
  14. package/docs/v11.5.1_RUST_FULL_LOOP_PLAN.md +85 -0
  15. package/docs/v11.5.2_TIGHT_SHIP_PLAN.md +144 -0
  16. package/lattice_brain/__init__.py +1 -1
  17. package/lattice_brain/graph/proactive.py +1 -1
  18. package/lattice_brain/graph/projection/v2_schema.py +0 -28
  19. package/lattice_brain/graph/vector_index/base.py +0 -3
  20. package/lattice_brain/graph/vector_index/brute_force.py +0 -4
  21. package/lattice_brain/graph/vector_index/hnsw.py +0 -3
  22. package/lattice_brain/graph/vector_index/quantized.py +0 -3
  23. package/lattice_brain/ingestion_jobs.py +0 -3
  24. package/lattice_brain/memory.py +0 -27
  25. package/lattice_brain/portability/sharing.py +0 -4
  26. package/lattice_brain/quality.py +0 -49
  27. package/lattice_brain/runtime/agent_runtime.py +1 -1
  28. package/lattice_brain/runtime/hooks.py +0 -13
  29. package/lattice_brain/runtime/multi_agent.py +1 -1
  30. package/lattice_brain/sealed_box.py +0 -4
  31. package/latticeai/__init__.py +1 -1
  32. package/latticeai/api/admin.py +12 -12
  33. package/latticeai/api/agent_worker_seam.py +389 -0
  34. package/latticeai/api/auth.py +25 -2
  35. package/latticeai/api/chat.py +3 -3
  36. package/latticeai/api/chat_agent_http.py +3 -12
  37. package/latticeai/api/chat_helpers.py +10 -13
  38. package/latticeai/api/computer_use.py +31 -42
  39. package/latticeai/api/health.py +21 -1
  40. package/latticeai/api/local_files.py +14 -0
  41. package/latticeai/api/permissions.py +38 -12
  42. package/latticeai/api/search.py +24 -0
  43. package/latticeai/api/static_routes.py +4 -1
  44. package/latticeai/cli/runtime.py +7 -3
  45. package/latticeai/core/config.py +47 -3
  46. package/latticeai/core/csrf.py +28 -2
  47. package/latticeai/core/embedding_providers/__init__.py +3 -5
  48. package/latticeai/core/embedding_providers/base.py +1 -1
  49. package/latticeai/core/embedding_providers/text.py +1 -1
  50. package/latticeai/core/enterprise.py +1 -4
  51. package/latticeai/core/http_origin.py +146 -0
  52. package/latticeai/core/invitations.py +3 -2
  53. package/latticeai/core/io_utils.py +2 -11
  54. package/latticeai/core/legacy_compatibility.py +1 -1
  55. package/latticeai/core/marketplace.py +1 -1
  56. package/latticeai/core/messages.py +40 -0
  57. package/latticeai/core/model_compat.py +2 -8
  58. package/latticeai/core/module_probe.py +37 -0
  59. package/latticeai/core/run_explain.py +5 -2
  60. package/latticeai/core/run_store.py +2 -2
  61. package/latticeai/core/security.py +13 -0
  62. package/latticeai/core/sessions.py +3 -2
  63. package/latticeai/core/sse.py +25 -0
  64. package/latticeai/core/users.py +0 -9
  65. package/latticeai/core/workspace_graph_trace.py +2 -8
  66. package/latticeai/core/workspace_os.py +0 -10
  67. package/latticeai/core/workspace_os_constants.py +1 -1
  68. package/latticeai/core/workspace_os_utils.py +34 -0
  69. package/latticeai/core/workspace_review_items.py +2 -8
  70. package/latticeai/core/workspace_runs.py +2 -8
  71. package/latticeai/core/workspace_skills.py +2 -7
  72. package/latticeai/models/router/documents.py +0 -35
  73. package/latticeai/models/router/registry.py +0 -4
  74. package/latticeai/runtime/access_runtime.py +16 -4
  75. package/latticeai/runtime/build_phases/features.py +25 -2
  76. package/latticeai/runtime/build_phases/web.py +2 -0
  77. package/latticeai/runtime/feature_toggle_wiring.py +12 -18
  78. package/latticeai/runtime/network_boundary_wiring.py +8 -19
  79. package/latticeai/runtime/permission_mode_wiring.py +6 -13
  80. package/latticeai/runtime/router_registration.py +4 -0
  81. package/latticeai/runtime/runtime_context.py +1 -13
  82. package/latticeai/runtime/service_singletons.py +55 -0
  83. package/latticeai/services/architecture_readiness.py +1 -1
  84. package/latticeai/services/brain_intelligence/proposals.py +0 -5
  85. package/latticeai/services/change_proposals.py +2 -36
  86. package/latticeai/services/chronicle.py +4 -6
  87. package/latticeai/services/command_center.py +4 -4
  88. package/latticeai/services/evidence_actions.py +5 -2
  89. package/latticeai/services/hybrid_chat.py +2 -2
  90. package/latticeai/services/hybrid_policy.py +8 -57
  91. package/latticeai/services/mode_store.py +132 -0
  92. package/latticeai/services/model_capability_registry.py +0 -15
  93. package/latticeai/services/model_catalog.py +0 -7
  94. package/latticeai/services/model_loading.py +3 -2
  95. package/latticeai/services/model_runtime/__init__.py +0 -57
  96. package/latticeai/services/model_runtime/engines.py +27 -128
  97. package/latticeai/services/model_runtime/loading.py +2 -2
  98. package/latticeai/services/network_boundary_service.py +7 -44
  99. package/latticeai/services/permission_mode_service.py +8 -56
  100. package/latticeai/services/product_readiness.py +1 -1
  101. package/latticeai/services/setup_detection.py +67 -1
  102. package/latticeai/services/tool_dispatch.py +0 -4
  103. package/latticeai/services/upload_service.py +7 -20
  104. package/latticeai/services/workspace_service.py +0 -6
  105. package/latticeai/setup/auto_setup.py +15 -26
  106. package/latticeai/setup/wizard/catalog.py +5 -2
  107. package/latticeai/setup/wizard/detect.py +6 -27
  108. package/latticeai/setup/wizard/paths.py +2 -5
  109. package/latticeai/setup/wizard/plans.py +2 -2
  110. package/package.json +2 -4
  111. package/scripts/bump_version.py +14 -1
  112. package/scripts/check_current_release_docs.mjs +1 -1
  113. package/scripts/check_legacy_debt.mjs +1 -1
  114. package/scripts/check_server_i18n.mjs +1 -0
  115. package/scripts/generate_agent_loop_fixtures.py +994 -0
  116. package/scripts/generate_rust_parity_fixtures.py +72 -161
  117. package/scripts/parity_fixture_corpus_context.py +162 -0
  118. package/scripts/parity_fixture_corpus_docgen.py +341 -0
  119. package/scripts/release_screen_claims.json +20 -0
  120. package/src-tauri/Cargo.lock +13 -7
  121. package/src-tauri/Cargo.toml +1 -1
  122. package/src-tauri/src/backend.rs +76 -2
  123. package/src-tauri/src/main.rs +13 -3
  124. package/src-tauri/tauri.conf.json +2 -2
  125. package/static/app/asset-manifest.json +41 -41
  126. package/static/app/assets/{Act-CWnxSCgN.js → Act-DcQizkl1.js} +1 -1
  127. package/static/app/assets/{AdminConsole-BEQYU6kF.js → AdminConsole-cf4npybT.js} +1 -1
  128. package/static/app/assets/{Brain-DWu1BhFg.js → Brain-3VCSHFcn.js} +1 -1
  129. package/static/app/assets/{BrainHome-95Hilr9R.js → BrainHome-Qm8eaztx.js} +1 -1
  130. package/static/app/assets/{BrainSignals-QdeqCpAF.js → BrainSignals-DS9BtKOW.js} +1 -1
  131. package/static/app/assets/{Capture-BHpCxnzb.js → Capture-DiQ219jW.js} +1 -1
  132. package/static/app/assets/{Chronicle-B4xYKoed.js → Chronicle-BGvuAchH.js} +1 -1
  133. package/static/app/assets/{CommandPalette-BVXnttSz.js → CommandPalette-Bqhm0Urn.js} +1 -1
  134. package/static/app/assets/{Library-DgYcHome.js → Library-BV6NnF0a.js} +1 -1
  135. package/static/app/assets/{LivingBrain-CrJLDbf7.js → LivingBrain-GzenJchP.js} +1 -1
  136. package/static/app/assets/{ProductFlow-DFlScKoJ.js → ProductFlow-DEP6-vML.js} +1 -1
  137. package/static/app/assets/{ReviewCard-Cy5f48Pj.js → ReviewCard-CNZ7XjWG.js} +1 -1
  138. package/static/app/assets/{System-NF8IfhTa.js → System-CieofHQa.js} +1 -1
  139. package/static/app/assets/arrow-left-kfsrk0mv.js +1 -0
  140. package/static/app/assets/{bot-CucuhLhm.js → bot-B_K1Tdmw.js} +1 -1
  141. package/static/app/assets/{brain-BBnSryW_.js → brain-DWyaV1L1.js} +1 -1
  142. package/static/app/assets/{button-C2GUj2Ai.js → button-aTn4s84A.js} +1 -1
  143. package/static/app/assets/circle-check-qqLug9nU.js +1 -0
  144. package/static/app/assets/{circle-pause-CbkWzBmG.js → circle-pause-xKgeGXkT.js} +1 -1
  145. package/static/app/assets/{circle-play-7lEaqHdJ.js → circle-play-DkT6tYPX.js} +1 -1
  146. package/static/app/assets/{cpu-DAlCXlIy.js → cpu-85xYObUC.js} +1 -1
  147. package/static/app/assets/{download-RNhuuJwh.js → download-B5Fm7YXo.js} +1 -1
  148. package/static/app/assets/{folder-open-CLW4odzM.js → folder-open-kk2Xa52u.js} +1 -1
  149. package/static/app/assets/{hard-drive-NKEiDIAJ.js → hard-drive-DkA3zBW_.js} +1 -1
  150. package/static/app/assets/{index-DMurvUuR.js → index-BMPdTmlY.js} +3 -3
  151. package/static/app/assets/index-DxmOfNRi.css +2 -0
  152. package/static/app/assets/{input-D2UhPC1X.js → input-B0nRf2jO.js} +1 -1
  153. package/static/app/assets/{link-2-6amKbP_P.js → link-2-Dwb4gnTc.js} +1 -1
  154. package/static/app/assets/{permissionCopy-Cu9TZtdR.js → permissionCopy-CQDUBrOZ.js} +1 -1
  155. package/static/app/assets/{primitives-gPsccucr.js → primitives-SNp0LRJz.js} +1 -1
  156. package/static/app/assets/search-BcHqkjoy.js +1 -0
  157. package/static/app/assets/{share-2-Bau7KkPq.js → share-2-BsrxFglO.js} +1 -1
  158. package/static/app/assets/{shield-alert-BufNYypi.js → shield-alert-5BStfp2_.js} +1 -1
  159. package/static/app/assets/{textarea-BQnVWhYs.js → textarea-Cg8IUA-k.js} +1 -1
  160. package/static/app/assets/{useFocusTrap-B3_w60si.js → useFocusTrap-CYKvE46M.js} +1 -1
  161. package/static/app/assets/{useMutation-BHhCflT6.js → useMutation-CSn9t1op.js} +1 -1
  162. package/static/app/assets/{useQuery-rBWfI-5t.js → useQuery-CY2OI2uy.js} +1 -1
  163. package/static/app/assets/{utils-V_5-wxr5.js → utils-Ddol2RWD.js} +1 -1
  164. package/static/app/assets/{workspace-K1zjYUHj.js → workspace-BqDwOz_p.js} +1 -1
  165. package/static/app/index.html +4 -4
  166. package/static/sw.js +1 -1
  167. package/desktop/electron/README.md +0 -9
  168. package/desktop/electron/main.cjs +0 -58
  169. package/desktop/electron/preload.cjs +0 -5
  170. package/latticeai/core/graph_curator.py +0 -11
  171. package/latticeai/core/hooks.py +0 -11
  172. package/latticeai/core/local_embeddings.py +0 -104
  173. package/latticeai/core/multi_agent.py +0 -11
  174. package/latticeai/core/workflow_engine.py +0 -11
  175. package/latticeai/services/ingestion.py +0 -11
  176. package/latticeai/services/kg_portability.py +0 -11
  177. package/latticeai/services/multimodal_streaming.py +0 -129
  178. package/scripts/measure_brain_home_fill.mjs +0 -141
  179. package/static/app/assets/arrow-left-DwkSYrjR.js +0 -1
  180. package/static/app/assets/circle-check-CxOVPwYq.js +0 -1
  181. package/static/app/assets/index-BLPb5lmE.css +0 -2
  182. package/static/app/assets/search-Cj_TKk_2.js +0 -1
@@ -11,7 +11,9 @@ from fastapi.responses import StreamingResponse
11
11
  from pydantic import BaseModel
12
12
 
13
13
  from lattice_brain.runtime.hooks import dispatch_tool
14
+ from latticeai.api.workspace_scope import resolve_workspace_scope
14
15
  from latticeai.core.agent import extract_action as _extract_agent_action
16
+ from latticeai.core.sse import sse_frame
15
17
  from latticeai.services.tool_dispatch import enforce_tool_policy
16
18
  from latticeai.tools import (
17
19
  AGENT_ROOT,
@@ -121,34 +123,24 @@ def create_computer_use_router(
121
123
  ) -> APIRouter:
122
124
  router = APIRouter()
123
125
 
124
- def _requested_workspace(request: Request) -> Optional[str]:
125
- header = request.headers.get("X-Workspace-Id")
126
- if header and header.strip():
127
- return header.strip()
128
- query = request.query_params.get("workspace_id")
129
- return query.strip() if query and query.strip() else None
130
-
131
126
  def _write_workspace(request: Request, current_user: str) -> Optional[str]:
132
127
  """Resolve only the CU agent's durable write scope.
133
128
 
134
129
  The other ``/cu`` actions remain host operations and do not acquire a
135
130
  workspace dependency. A no-auth local request without an explicit
136
- scope preserves its legacy unscoped history behavior; explicit scopes
137
- and every authenticated request go through the normal write gate.
131
+ scope preserves its legacy unscoped history behavior — that is exactly
132
+ what ``allow_unscoped_anonymous`` names in the shared resolver;
133
+ explicit scopes and every authenticated request go through the normal
134
+ write gate.
138
135
  """
139
136
 
140
- requested = _requested_workspace(request)
141
- if workspace_service is None:
142
- return requested
143
- if not current_user and requested is None:
144
- return None
145
- try:
146
- return workspace_service.resolve_write_scope(
147
- requested,
148
- current_user or None,
149
- )
150
- except PermissionError as exc:
151
- raise HTTPException(status_code=403, detail=str(exc)) from exc
137
+ return resolve_workspace_scope(
138
+ request,
139
+ user=current_user,
140
+ workspace_service=workspace_service,
141
+ write=True,
142
+ allow_unscoped_anonymous=True,
143
+ )
152
144
 
153
145
  def _audit_args(name: str, args: dict) -> dict:
154
146
  """Return audit-safe argument metadata without storing typed text."""
@@ -351,17 +343,14 @@ def create_computer_use_router(
351
343
  task_lower = (req.task or "").lower()
352
344
  url_match = re.search(r"(https?://[^\s]+|localhost:\d+[^\s]*|127\.0\.0\.1:\d+[^\s]*)", req.task or "")
353
345
 
354
- def _send(event: str, data: dict) -> str:
355
- return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
356
-
357
346
  if ("chrome" in task_lower or "크롬" in task_lower) and any(
358
347
  word in task_lower for word in ["open", "열", "켜", "실행", "띄"]
359
348
  ):
360
- yield _send("start", {"task": req.task, "max_steps": 1})
349
+ yield sse_frame("start", {"task": req.task, "max_steps": 1})
361
350
  try:
362
351
  if url_match:
363
352
  url = url_match.group(1)
364
- yield _send(
353
+ yield sse_frame(
365
354
  "action",
366
355
  {"step": 1, "action": "computer_open_url", "args": {"url": url, "app": "Google Chrome"}},
367
356
  )
@@ -371,11 +360,11 @@ def create_computer_use_router(
371
360
  lambda: computer_open_url(url, "Google Chrome"),
372
361
  current_user=current_user,
373
362
  )
374
- yield _send("result", {"step": 1, "action": "computer_open_url", "result": result})
363
+ yield sse_frame("result", {"step": 1, "action": "computer_open_url", "result": result})
375
364
  message = f"Google Chrome에서 {url}을 열었습니다."
376
365
  action_name = "computer_open_url"
377
366
  else:
378
- yield _send(
367
+ yield sse_frame(
379
368
  "action",
380
369
  {"step": 1, "action": "computer_open_app", "args": {"app": "Google Chrome"}},
381
370
  )
@@ -385,26 +374,26 @@ def create_computer_use_router(
385
374
  lambda: computer_open_app("Google Chrome"),
386
375
  current_user=current_user,
387
376
  )
388
- yield _send("result", {"step": 1, "action": "computer_open_app", "result": result})
377
+ yield sse_frame("result", {"step": 1, "action": "computer_open_app", "result": result})
389
378
  message = "Google Chrome을 열었습니다."
390
379
  action_name = "computer_open_app"
391
380
  _save_completed("user", req.task)
392
381
  _save_completed("assistant", message)
393
- yield _send("final", {"message": message, "steps": [{"step": 1, "action": action_name, "result": result}]})
382
+ yield sse_frame("final", {"message": message, "steps": [{"step": 1, "action": action_name, "result": result}]})
394
383
  except HTTPException as exc:
395
- yield _send("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc.detail)})
384
+ yield sse_frame("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc.detail)})
396
385
  except (ToolError, PermissionError) as exc:
397
- yield _send("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc)})
386
+ yield sse_frame("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc)})
398
387
  return
399
388
 
400
389
  if not model_router.current_model_id:
401
- yield _send("error", {"error": "No model loaded."})
390
+ yield sse_frame("error", {"error": "No model loaded."})
402
391
  return
403
392
 
404
393
  transcript: List[Dict[str, Any]] = []
405
394
  last_screenshot_b64: Optional[str] = None
406
395
  max_steps = max(1, min(req.max_steps, 20))
407
- yield _send("start", {"task": req.task, "max_steps": max_steps})
396
+ yield sse_frame("start", {"task": req.task, "max_steps": max_steps})
408
397
 
409
398
  for step in range(max_steps):
410
399
  context = (
@@ -423,7 +412,7 @@ def create_computer_use_router(
423
412
  try:
424
413
  action = _extract_agent_action(str(raw))
425
414
  except ValueError as exc:
426
- yield _send("error", {"step": step + 1, "error": str(exc), "raw": str(raw)})
415
+ yield sse_frame("error", {"step": step + 1, "error": str(exc), "raw": str(raw)})
427
416
  break
428
417
 
429
418
  name = action.get("action")
@@ -432,10 +421,10 @@ def create_computer_use_router(
432
421
  message = action.get("message", "작업을 완료했습니다.")
433
422
  _save_completed("user", req.task)
434
423
  _save_completed("assistant", message)
435
- yield _send("final", {"message": message, "steps": transcript})
424
+ yield sse_frame("final", {"message": message, "steps": transcript})
436
425
  return
437
426
 
438
- yield _send("action", {"step": step + 1, "action": name, "args": args})
427
+ yield sse_frame("action", {"step": step + 1, "action": name, "args": args})
439
428
  try:
440
429
  result = _dispatch(
441
430
  name,
@@ -449,7 +438,7 @@ def create_computer_use_router(
449
438
  result_summary = {k: v for k, v in result.items() if k != "screenshot_b64"}
450
439
  result_summary["screenshot_captured"] = True
451
440
  transcript.append({"step": step + 1, "action": name, "args": args, "result": result_summary})
452
- yield _send(
441
+ yield sse_frame(
453
442
  "screenshot",
454
443
  {
455
444
  "step": step + 1,
@@ -461,17 +450,17 @@ def create_computer_use_router(
461
450
  else:
462
451
  last_screenshot_b64 = None
463
452
  transcript.append({"step": step + 1, "action": name, "args": args, "result": result})
464
- yield _send("result", {"step": step + 1, "action": name, "result": result})
453
+ yield sse_frame("result", {"step": step + 1, "action": name, "result": result})
465
454
  except HTTPException as exc:
466
455
  error_str = str(exc.detail)
467
456
  transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
468
- yield _send("tool_error", {"step": step + 1, "action": name, "error": error_str})
457
+ yield sse_frame("tool_error", {"step": step + 1, "action": name, "error": error_str})
469
458
  except (ToolError, PermissionError, KeyError, TypeError) as exc:
470
459
  error_str = str(exc)
471
460
  transcript.append({"step": step + 1, "action": name, "args": args, "error": error_str})
472
- yield _send("tool_error", {"step": step + 1, "action": name, "error": error_str})
461
+ yield sse_frame("tool_error", {"step": step + 1, "action": name, "error": error_str})
473
462
 
474
- yield _send("done", {"steps": len(transcript), "transcript": transcript})
463
+ yield sse_frame("done", {"steps": len(transcript), "transcript": transcript})
475
464
 
476
465
  return StreamingResponse(
477
466
  _stream(),
@@ -19,6 +19,7 @@ def create_health_router(
19
19
  engine_status: Callable[[], List[dict]],
20
20
  get_current_user: Callable[[Request], Optional[str]],
21
21
  require_auth: bool,
22
+ externally_reachable: bool,
22
23
  app_version: str,
23
24
  app_mode: str,
24
25
  ) -> APIRouter:
@@ -29,9 +30,28 @@ def create_health_router(
29
30
  if require_auth and not get_current_user(request):
30
31
  raise HTTPException(status_code=401, detail="인증이 필요합니다.")
31
32
 
33
+ def _access_posture() -> dict:
34
+ """The two facts ``trusted_local_owner`` is computed from.
35
+
36
+ Stated on the *unauthenticated* part of ``/health`` on purpose: the Rust
37
+ front door polls this endpoint already, and it gates its own native
38
+ ``/rust/*`` lanes on the answer. Those lanes read the store with no
39
+ credential at all, so without this they had no way to tell an
40
+ optional-auth loopback worker (where that is exactly right) from a
41
+ ``LATTICEAI_REQUIRE_AUTH=true`` one (where it is a hole). Neither field
42
+ is a secret: an unauthenticated caller discovers both by being refused.
43
+ """
44
+ return {
45
+ "require_auth": bool(require_auth),
46
+ "externally_reachable": bool(externally_reachable),
47
+ }
48
+
32
49
  @router.get("/health")
33
50
  async def health(request: Request):
34
- base = svc.health_base(version=app_version, mode=app_mode)
51
+ base = {
52
+ **svc.health_base(version=app_version, mode=app_mode),
53
+ "access": _access_posture(),
54
+ }
35
55
  if not get_current_user(request) and require_auth:
36
56
  return base
37
57
  engines = await asyncio.to_thread(engine_status)
@@ -310,6 +310,20 @@ def create_local_files_router(
310
310
  except PermissionError as exc:
311
311
  raise HTTPException(status_code=403, detail=str(exc)) from exc
312
312
 
313
+ @router.get("/api/ingestion/multimodal")
314
+ async def ingestion_multimodal_status(request: Request):
315
+ """What this install will do with a picture, a recording, or a video.
316
+
317
+ FEATURE_STATUS has described this answer — including which of the three
318
+ reasons a video would be refused for — since 11.2.0, but the pipeline
319
+ method that produces it had no route, so no surface could ever show it
320
+ and "why was my video not indexed?" had no answer short of reading the
321
+ server's environment.
322
+ """
323
+ require_user(request)
324
+ _require_pipeline()
325
+ return ingestion_pipeline.multimodal_status()
326
+
313
327
  @router.get("/api/ingestion/jobs")
314
328
  async def ingestion_jobs(request: Request, limit: int = 20):
315
329
  """Recent background ingestion jobs (newest first)."""
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import hashlib
6
5
  import json
7
6
  import logging
8
7
  import os
@@ -15,7 +14,9 @@ from typing import Any, Dict, Optional, Tuple
15
14
 
16
15
  from fastapi import APIRouter, HTTPException, Request
17
16
 
17
+ from latticeai.core.http_origin import request_external_origin
18
18
  from latticeai.core.io_utils import atomic_write_json
19
+ from latticeai.core.security import sha256_hex
19
20
 
20
21
  _PERMISSION_ACTION_LABELS = {
21
22
  "list": "폴더 목록 보기",
@@ -39,16 +40,32 @@ class PermissionGateway:
39
40
  self.discord_permission_channel = config.discord_permission_channel
40
41
  self.permission_monitor_secret = config.permission_monitor_secret
41
42
  self.perm_queue_file = data_dir / "permission_queue.json"
42
- explicit_ui_url = os.getenv("LATTICEAI_PERMISSION_UI_URL", "").strip()
43
- public_url = os.getenv("LATTICEAI_PUBLIC_URL", "").strip().rstrip("/")
44
- default_ui_url = f"http://127.0.0.1:{getattr(config, 'port', 4825)}/app#/admin/permissions"
45
- self.permission_ui_url = explicit_ui_url or (
46
- f"{public_url}/app#/admin/permissions" if public_url else default_ui_url
47
- )
43
+ self._explicit_ui_url = os.getenv("LATTICEAI_PERMISSION_UI_URL", "").strip()
44
+ self._public_url = os.getenv("LATTICEAI_PUBLIC_URL", "").strip().rstrip("/")
45
+ # The worker's own port. Behind the desktop gateway this is the
46
+ # *internal* one, which is why a notification built from it sends the
47
+ # approver to a door only the gateway can open — hence
48
+ # ``_front_door``, learned from the request that asked for approval.
49
+ self._loopback_ui_origin = f"http://127.0.0.1:{getattr(config, 'port', 4825)}"
50
+ self._front_door: Optional[str] = None
51
+
52
+ @property
53
+ def permission_ui_url(self) -> str:
54
+ """Where to send the person who has to approve this.
55
+
56
+ Operator configuration wins — an explicit URL or a public URL is a
57
+ deliberate statement about how this install is reached. Only when
58
+ nothing is configured does the front door observed on the request that
59
+ triggered the approval beat the worker's own loopback address.
60
+ """
61
+ if self._explicit_ui_url:
62
+ return self._explicit_ui_url
63
+ base = self._public_url or self._front_door or self._loopback_ui_origin
64
+ return f"{base.rstrip('/')}/app#/admin/permissions"
48
65
 
49
66
  @staticmethod
50
67
  def token_hash(token: str) -> str:
51
- return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()
68
+ return sha256_hex(str(token or ""))
52
69
 
53
70
  @staticmethod
54
71
  def token_hint(token: str) -> str:
@@ -78,9 +95,6 @@ class PermissionGateway:
78
95
  except Exception as exc:
79
96
  logging.warning("perm_queue_write failed: %s", exc)
80
97
 
81
- def _perm_queue_remove(self, token: str) -> None:
82
- self._perm_queue_remove_key(self.token_hash(token))
83
-
84
98
  def _perm_queue_remove_key(self, key: str) -> None:
85
99
  try:
86
100
  with self.perm_queue_lock:
@@ -117,7 +131,7 @@ class PermissionGateway:
117
131
 
118
132
  @staticmethod
119
133
  def content_fingerprint(content: str = "") -> str:
120
- return hashlib.sha256(content.encode("utf-8")).hexdigest()
134
+ return sha256_hex(content)
121
135
 
122
136
  def _notify_discord_permission_sync(self, token: str, path: str, action: str, user_email: str) -> None:
123
137
  sent = False
@@ -225,8 +239,20 @@ class PermissionGateway:
225
239
  email = self.get_current_user(request)
226
240
  if not email:
227
241
  raise HTTPException(status_code=401, detail="로컬 파일 접근은 로그인 세션이 필요합니다.")
242
+ # Every local-file approval passes through here immediately before the
243
+ # permission record (and its notification) is created, so this is the
244
+ # request whose front door the approver should be sent back to. The
245
+ # forwarded authority is only believed from a loopback or configured
246
+ # proxy peer — the same rule CSRF and the invite link use.
247
+ self.remember_front_door(request)
228
248
  return email
229
249
 
250
+ def remember_front_door(self, request: Request) -> None:
251
+ """Record the external origin this request came through, if any."""
252
+ origin = request_external_origin(request)
253
+ if origin:
254
+ self._front_door = origin
255
+
230
256
  def require_local_approval(
231
257
  self,
232
258
  *,
@@ -32,6 +32,12 @@ class HybridSearchRequest(SearchRequest):
32
32
  image_fusion: bool = False
33
33
 
34
34
 
35
+ class GraphSearchRequest(SearchRequest):
36
+ #: How far to walk out from the directly-matching nodes. The service
37
+ #: clamps this to 0–3; 0 means "matches only, no expansion".
38
+ expand_depth: int = 1
39
+
40
+
35
41
  class GraphNodeRequest(BaseModel):
36
42
  node_id: str = Field(..., min_length=1)
37
43
  include_neighbors: bool = True
@@ -164,6 +170,24 @@ def create_search_router(
164
170
  except ValueError as exc:
165
171
  _raise_graph_error(exc)
166
172
 
173
+ @router.post("/api/search/graph")
174
+ async def graph_search(req: GraphSearchRequest, request: Request) -> Dict[str, Any]:
175
+ """Relationship-aware search: direct matches plus what they connect to.
176
+
177
+ The third retrieval channel. It was in ``_ScopedSearchService._SCOPED``
178
+ from the start — so the workspace guard was ready for it — but no
179
+ handler ever called it, which made the allow-list entry the only
180
+ evidence the channel was supposed to be reachable at all.
181
+ """
182
+ try:
183
+ return _guarded(request).graph_search(
184
+ req.query,
185
+ limit=req.limit,
186
+ expand_depth=req.expand_depth,
187
+ )
188
+ except ValueError as exc:
189
+ _raise_graph_error(exc)
190
+
167
191
  @router.get("/api/graph")
168
192
  async def graph(request: Request, limit: int = 300) -> Dict[str, Any]:
169
193
  try:
@@ -25,7 +25,10 @@ PRODUCTION_CSP = (
25
25
  "style-src 'self' 'unsafe-inline'; "
26
26
  "img-src 'self' data: blob: http://127.0.0.1:*; "
27
27
  "font-src 'self' data:; "
28
- "connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; "
28
+ # No `ws:`: this product has no WebSocket endpoint anywhere. The live
29
+ # surfaces (chat, agent steps, index progress) are server-sent events, read
30
+ # with fetch/ReadableStream, which `connect-src` already covers over http.
31
+ "connect-src 'self' http://127.0.0.1:*; "
29
32
  "frame-src 'none'; "
30
33
  "object-src 'none'; "
31
34
  "base-uri 'none'; "
@@ -2,10 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import importlib.util
6
5
  import os
7
6
  from pathlib import Path
8
7
 
8
+ from latticeai.core.module_probe import module_available
9
+
10
+ #: Historical name. ``cli.entrypoint`` imports it from here and the suite
11
+ #: monkeypatches it there, so the binding has to exist on this module.
12
+ _has_module = module_available
13
+
9
14
 
10
15
  def _load_env_file(path: Path) -> None:
11
16
  if not path.exists():
@@ -33,5 +38,4 @@ def _apply_extra_path() -> None:
33
38
  os.environ["PATH"] = os.pathsep.join(current)
34
39
 
35
40
 
36
- def _has_module(name: str) -> bool:
37
- return importlib.util.find_spec(name) is not None
41
+
@@ -18,6 +18,7 @@ concept and are read dynamically at call time.
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import os
21
22
  import sys
22
23
  from dataclasses import dataclass
23
24
  from pathlib import Path
@@ -25,7 +26,50 @@ from typing import List, Mapping, Optional
25
26
 
26
27
  from latticeai.core.security import host_is_loopback
27
28
 
28
- __all__ = ["Config", "_value", "_str", "_bool", "_int"]
29
+ __all__ = [
30
+ "Config",
31
+ "SSO_CALLBACK_PATH",
32
+ "default_data_dir",
33
+ "default_sso_redirect_uri",
34
+ "_value",
35
+ "_str",
36
+ "_bool",
37
+ "_int",
38
+ ]
39
+
40
+ #: The path the identity provider sends the browser back to.
41
+ SSO_CALLBACK_PATH = "/auth/sso/callback"
42
+
43
+
44
+ def default_sso_redirect_uri(port: int) -> str:
45
+ """The redirect URI when the operator has registered none.
46
+
47
+ Named rather than inlined because ``api/auth`` has to recognise it: this
48
+ exact string means "nobody chose a callback URL", which is the only case in
49
+ which the login flow may substitute the front door it was actually reached
50
+ through. A URI the operator *did* configure is registered with the identity
51
+ provider and must be sent back verbatim.
52
+ """
53
+ return f"http://localhost:{port}{SSO_CALLBACK_PATH}"
54
+
55
+
56
+ #: Where everything durable lives when the operator names nothing.
57
+ DEFAULT_DATA_DIR_NAME = ".ltcai"
58
+
59
+
60
+ def default_data_dir(env: Optional[Mapping[str, str]] = None) -> Path:
61
+ """The data directory ``LATTICEAI_DATA_DIR`` names, else ``~/.ltcai``.
62
+
63
+ Three runtime wiring modules each carried a byte-identical copy of this,
64
+ which meant a lazily-initialized service could disagree with ``Config``
65
+ about where the store lives — and the loser writes a second, invisible
66
+ copy of the user's data. ``env=None`` reads the live process environment,
67
+ which is what the lazy wiring paths need.
68
+ """
69
+ source = os.environ if env is None else env
70
+ raw = (source.get("LATTICEAI_DATA_DIR") or "").strip()
71
+ return Path(raw) if raw else Path.home() / DEFAULT_DATA_DIR_NAME
72
+
29
73
 
30
74
  def _value(env: Mapping[str, str], key: str, default: str = "") -> str:
31
75
  """Mirror the legacy ``env_value``: ``getenv(key) or default or ""`` (no strip)."""
@@ -177,14 +221,14 @@ class Config:
177
221
  # download path, the on-disk cache dir and the catalog key all agree.
178
222
  local_model = _value(env, "LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-12B-it-4bit")
179
223
 
180
- data_dir = Path(_value(env, "LATTICEAI_DATA_DIR", str(Path.home() / ".ltcai")))
224
+ data_dir = default_data_dir(env)
181
225
  static_dir = Path(_value(env, "LATTICEAI_STATIC_DIR", str(base_dir / "static")))
182
226
  if not static_dir.exists():
183
227
  packaged_static = Path(sys.prefix) / "static"
184
228
  if packaged_static.exists():
185
229
  static_dir = packaged_static
186
230
 
187
- default_sso_redirect = f"http://localhost:{port}/auth/sso/callback"
231
+ default_sso_redirect = default_sso_redirect_uri(port)
188
232
 
189
233
  return cls(
190
234
  app_mode=app_mode,
@@ -29,6 +29,12 @@ Threat model, stated so the exemptions are checkable rather than vibes:
29
29
  the desktop shell, the VS Code extension). That is trusted only while the
30
30
  server is bound to loopback, where "not a browser" and "already on this
31
31
  machine" coincide. On a reachable bind it fails closed.
32
+ * **Behind a proxy, ``Host`` is not the front door.** The desktop gateway
33
+ replaces it with the worker's internal authority, so the ``Origin == Host``
34
+ fallback below compared a browser's real origin against a port the browser
35
+ has never heard of and refused every write. The front door's own name comes
36
+ from :mod:`latticeai.core.http_origin`, which believes ``X-Forwarded-Host``
37
+ only from a peer that may forward.
32
38
  """
33
39
 
34
40
  from __future__ import annotations
@@ -48,6 +54,11 @@ from typing import (
48
54
  )
49
55
  from urllib.parse import urlsplit
50
56
 
57
+ from latticeai.core.http_origin import (
58
+ FORWARDED_HOST_HEADER,
59
+ effective_host,
60
+ )
61
+
51
62
  __all__ = [
52
63
  "CSRFDecision",
53
64
  "CSRFOriginPolicy",
@@ -176,6 +187,8 @@ class CSRFOriginPolicy:
176
187
  self,
177
188
  origin: Tuple[str, str, Optional[int]],
178
189
  host_header: Optional[str],
190
+ forwarded_host: Optional[str] = None,
191
+ peer: Optional[str] = None,
179
192
  ) -> bool:
180
193
  if any(_same_site(origin, trusted) for trusted in self._trusted):
181
194
  return True
@@ -184,7 +197,13 @@ class CSRFOriginPolicy:
184
197
  # page this server actually served — which is precisely "not cross
185
198
  # site". This is what keeps reverse-proxied hostnames working without
186
199
  # every operator having to enumerate them.
187
- own = normalize_origin(host_header)
200
+ #
201
+ # "The request's own Host" is the *front door's*, not the internal one a
202
+ # proxy hop rewrote it to, which is why the forwarded authority is
203
+ # resolved first (and only from a peer that may forward).
204
+ own = normalize_origin(
205
+ effective_host(host=host_header, forwarded_host=forwarded_host, peer=peer)
206
+ )
188
207
  return own is not None and _same_site(origin, own)
189
208
 
190
209
  def evaluate(
@@ -196,6 +215,8 @@ class CSRFOriginPolicy:
196
215
  host: Optional[str],
197
216
  cookie_header: Optional[str],
198
217
  authorization: Optional[str],
218
+ forwarded_host: Optional[str] = None,
219
+ peer: Optional[str] = None,
199
220
  ) -> CSRFDecision:
200
221
  """Allow/deny one request. Pure: no I/O, no app state."""
201
222
  if method.upper() in SAFE_METHODS:
@@ -219,7 +240,7 @@ class CSRFOriginPolicy:
219
240
  return CSRFDecision(True, "no-origin-loopback-bind")
220
241
  return CSRFDecision(False, "no-origin-reachable-bind")
221
242
 
222
- if self._origin_is_trusted(stated, host):
243
+ if self._origin_is_trusted(stated, host, forwarded_host, peer):
223
244
  return CSRFDecision(True, "same-site-or-trusted-origin")
224
245
  return CSRFDecision(False, "cross-site-origin")
225
246
 
@@ -261,6 +282,9 @@ class CSRFOriginGuardMiddleware:
261
282
  key.decode("latin-1").lower(): value.decode("latin-1")
262
283
  for key, value in scope.get("headers") or []
263
284
  }
285
+ # ``client`` is ``(host, port)`` under every ASGI server, and absent
286
+ # under a test transport that does not model a socket.
287
+ client = scope.get("client") or ()
264
288
  decision = self.policy.evaluate(
265
289
  method=str(scope.get("method") or "GET"),
266
290
  origin=headers.get("origin"),
@@ -268,6 +292,8 @@ class CSRFOriginGuardMiddleware:
268
292
  host=headers.get("host"),
269
293
  cookie_header=headers.get("cookie"),
270
294
  authorization=headers.get("authorization"),
295
+ forwarded_host=headers.get(FORWARDED_HOST_HEADER),
296
+ peer=client[0] if client else None,
271
297
  )
272
298
  if decision.allowed:
273
299
  await self.app(scope, receive, send)
@@ -15,7 +15,7 @@ swapped behind a single interface as long as every implementation agrees on:
15
15
  holds the five concrete text implementations:
16
16
 
17
17
  1. :class:`HashEmbeddingProvider` — deterministic, offline, always-available
18
- fallback (wraps the legacy :class:`~latticeai.core.local_embeddings.LocalEmbeddingModel`).
18
+ fallback (wraps :class:`~lattice_brain.embeddings.LocalEmbeddingModel`).
19
19
  2. :class:`MLXEmbeddingProvider` — local Apple-Silicon embedding models.
20
20
  3. :class:`OllamaEmbeddingProvider` — a local/remote Ollama server.
21
21
  4. :class:`OpenAICompatibleEmbeddingProvider` — any ``/v1/embeddings`` endpoint
@@ -65,10 +65,8 @@ from __future__ import annotations
65
65
  # the two names it imported for its own use were part of its surface, and the
66
66
  # suite imports them from here. Re-exported in the redundant-alias form so they
67
67
  # read as deliberate rather than as leftover imports.
68
- from latticeai.core.local_embeddings import (
69
- DEFAULT_EMBEDDING_DIM as DEFAULT_EMBEDDING_DIM,
70
- )
71
- from latticeai.core.local_embeddings import LocalEmbeddingModel as LocalEmbeddingModel
68
+ from lattice_brain.embeddings import DEFAULT_EMBEDDING_DIM as DEFAULT_EMBEDDING_DIM
69
+ from lattice_brain.embeddings import LocalEmbeddingModel as LocalEmbeddingModel
72
70
 
73
71
  from .base import _KNOWN_DIMS as _KNOWN_DIMS
74
72
  from .base import EmbeddingProvider as EmbeddingProvider
@@ -19,7 +19,7 @@ import struct
19
19
  from dataclasses import dataclass, field
20
20
  from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence
21
21
 
22
- from latticeai.core.local_embeddings import DEFAULT_EMBEDDING_DIM
22
+ from lattice_brain.embeddings import DEFAULT_EMBEDDING_DIM
23
23
 
24
24
 
25
25
  class EmbeddingUnavailable(RuntimeError):
@@ -14,7 +14,7 @@ import os
14
14
  from dataclasses import dataclass
15
15
  from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
16
16
 
17
- from latticeai.core.local_embeddings import DEFAULT_EMBEDDING_DIM, LocalEmbeddingModel
17
+ from lattice_brain.embeddings import DEFAULT_EMBEDDING_DIM, LocalEmbeddingModel
18
18
 
19
19
  from .base import (
20
20
  EmbeddingProvider,
@@ -22,7 +22,7 @@ from __future__ import annotations
22
22
 
23
23
  import os
24
24
  from enum import Enum
25
- from typing import Dict, List, Protocol, runtime_checkable
25
+ from typing import Dict, Protocol, runtime_checkable
26
26
 
27
27
 
28
28
  class Edition(str, Enum):
@@ -115,9 +115,6 @@ class CapabilityRegistry:
115
115
  # A misbehaving plugin must never break a Community request.
116
116
  return False
117
117
 
118
- def available_capabilities(self) -> List[EnterpriseCapability]:
119
- return [cap for cap in EnterpriseCapability if self.is_capability_enabled(cap)]
120
-
121
118
  def describe(self) -> Dict[str, object]:
122
119
  """Edition + capability matrix for ``/workspace/editions`` and admin UI."""
123
120
  return {