ltcai 10.2.0 → 10.4.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 (149) hide show
  1. package/README.md +40 -44
  2. package/docs/CHANGELOG.md +125 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/MYPY_BACKLOG.md +65 -0
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +1 -1
  8. package/docs/SURFACE_PARITY.md +33 -6
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/context.py +23 -3
  14. package/lattice_brain/graph/_kg_common.py +164 -9
  15. package/lattice_brain/graph/_kg_contract.py +223 -0
  16. package/lattice_brain/graph/_kg_fsutil.py +2 -2
  17. package/lattice_brain/graph/curator.py +4 -4
  18. package/lattice_brain/graph/discovery.py +12 -1
  19. package/lattice_brain/graph/discovery_index.py +12 -1
  20. package/lattice_brain/graph/documents.py +17 -2
  21. package/lattice_brain/graph/ingest.py +12 -1
  22. package/lattice_brain/graph/proactive.py +9 -5
  23. package/lattice_brain/graph/projection.py +12 -1
  24. package/lattice_brain/graph/provenance.py +12 -1
  25. package/lattice_brain/graph/retrieval.py +12 -1
  26. package/lattice_brain/graph/retrieval_docgen.py +12 -1
  27. package/lattice_brain/graph/retrieval_reads.py +12 -1
  28. package/lattice_brain/graph/retrieval_vector.py +12 -1
  29. package/lattice_brain/graph/schema.py +6 -2
  30. package/lattice_brain/graph/write_master.py +12 -1
  31. package/lattice_brain/ingestion.py +1 -1
  32. package/lattice_brain/quality.py +5 -5
  33. package/lattice_brain/runtime/agent_runtime.py +7 -2
  34. package/lattice_brain/runtime/contracts.py +2 -1
  35. package/lattice_brain/runtime/hooks.py +1 -1
  36. package/lattice_brain/runtime/multi_agent.py +1 -1
  37. package/lattice_brain/workflow.py +10 -7
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/admin.py +2 -2
  40. package/latticeai/api/chat.py +19 -15
  41. package/latticeai/api/chat_agent_http.py +19 -7
  42. package/latticeai/api/chat_helpers.py +9 -8
  43. package/latticeai/api/chat_stream.py +2 -2
  44. package/latticeai/api/computer_use.py +2 -2
  45. package/latticeai/api/hooks.py +4 -2
  46. package/latticeai/api/local_files.py +8 -4
  47. package/latticeai/api/mcp.py +2 -2
  48. package/latticeai/api/models.py +5 -5
  49. package/latticeai/api/permissions.py +2 -2
  50. package/latticeai/api/search.py +5 -3
  51. package/latticeai/api/security_dashboard.py +3 -5
  52. package/latticeai/api/setup.py +3 -3
  53. package/latticeai/api/static_routes.py +7 -2
  54. package/latticeai/api/tools.py +9 -4
  55. package/latticeai/api/workspace.py +25 -22
  56. package/latticeai/app_factory.py +132 -1346
  57. package/latticeai/cli/entrypoint.py +1 -1
  58. package/latticeai/core/agent.py +28 -14
  59. package/latticeai/core/audit.py +5 -3
  60. package/latticeai/core/embedding_providers.py +19 -6
  61. package/latticeai/core/legacy_compatibility.py +1 -1
  62. package/latticeai/core/marketplace.py +2 -2
  63. package/latticeai/core/mcp_catalog.py +3 -1
  64. package/latticeai/core/model_compat.py +1 -1
  65. package/latticeai/core/plugins.py +1 -1
  66. package/latticeai/core/project_sessions.py +1 -1
  67. package/latticeai/core/run_explain.py +6 -4
  68. package/latticeai/core/workspace_os.py +0 -4
  69. package/latticeai/core/workspace_os_constants.py +1 -1
  70. package/latticeai/core/workspace_os_state.py +1 -1
  71. package/latticeai/core/workspace_snapshots.py +3 -3
  72. package/latticeai/integrations/telegram_bot.py +52 -3
  73. package/latticeai/models/router.py +61 -34
  74. package/latticeai/runtime/access_runtime.py +3 -1
  75. package/latticeai/runtime/build_phases.py +1417 -0
  76. package/latticeai/runtime/history_writer.py +138 -0
  77. package/latticeai/runtime/review_wiring.py +6 -1
  78. package/latticeai/runtime/runtime_context.py +276 -0
  79. package/latticeai/runtime/sso_config_runtime.py +1 -1
  80. package/latticeai/runtime/stages.py +8 -2
  81. package/latticeai/services/app_context.py +23 -0
  82. package/latticeai/services/architecture_readiness.py +1 -1
  83. package/latticeai/services/brain_intelligence.py +4 -2
  84. package/latticeai/services/cloud_streaming.py +5 -3
  85. package/latticeai/services/command_center.py +1 -1
  86. package/latticeai/services/evidence_actions.py +2 -1
  87. package/latticeai/services/hybrid_chat.py +1 -1
  88. package/latticeai/services/local_knowledge.py +25 -9
  89. package/latticeai/services/memory_service.py +8 -4
  90. package/latticeai/services/model_catalog.py +12 -12
  91. package/latticeai/services/model_engines.py +15 -17
  92. package/latticeai/services/model_loading.py +1 -1
  93. package/latticeai/services/model_runtime.py +31 -33
  94. package/latticeai/services/multimodal_streaming.py +10 -4
  95. package/latticeai/services/openai_compatible_adapter.py +4 -3
  96. package/latticeai/services/product_readiness.py +1 -1
  97. package/latticeai/services/run_executor.py +12 -3
  98. package/latticeai/services/search_service.py +1 -1
  99. package/latticeai/services/triggers.py +1 -1
  100. package/latticeai/setup/auto_setup.py +1 -1
  101. package/latticeai/setup/wizard.py +12 -3
  102. package/latticeai/tools/computer.py +3 -1
  103. package/latticeai/tools/documents.py +2 -2
  104. package/package.json +4 -2
  105. package/scripts/check_current_release_docs.mjs +1 -1
  106. package/src-tauri/Cargo.lock +1 -1
  107. package/src-tauri/Cargo.toml +1 -1
  108. package/src-tauri/tauri.conf.json +1 -1
  109. package/static/app/asset-manifest.json +37 -37
  110. package/static/app/assets/{Act-CbdGD-2i.js → Act-BK__Y5VE.js} +1 -1
  111. package/static/app/assets/{AdminConsole-LgCkXpnf.js → AdminConsole-ZrTyN8_F.js} +1 -1
  112. package/static/app/assets/{Brain-CoPGJI1L.js → Brain-CYHDXpPB.js} +1 -1
  113. package/static/app/assets/{BrainHome-gVnaxSwp.js → BrainHome-bbJulb5g.js} +1 -1
  114. package/static/app/assets/{BrainSignals-ChxAYHtj.js → BrainSignals-CesSPtUP.js} +1 -1
  115. package/static/app/assets/{Capture-DBtgkHZg.js → Capture-p5upXmz2.js} +1 -1
  116. package/static/app/assets/{CommandPalette-Ovtv5I0Y.js → CommandPalette-0zkyC0RY.js} +1 -1
  117. package/static/app/assets/Library-Dt-vABI2.js +1 -0
  118. package/static/app/assets/{LivingBrain-CNz-6NSd.js → LivingBrain-Bz-h6LBH.js} +1 -1
  119. package/static/app/assets/{ProductFlow-ePX-Y73J.js → ProductFlow-DAZ7DJCE.js} +1 -1
  120. package/static/app/assets/{ReviewCard-C4mpvkwH.js → ReviewCard-Bv0WGbY6.js} +1 -1
  121. package/static/app/assets/{System-BvWNK1zc.js → System-BhrJQSD_.js} +1 -1
  122. package/static/app/assets/{activity-CiauPV_3.js → activity-AolpZJCH.js} +1 -1
  123. package/static/app/assets/{bot-Dwct-Q1x.js → bot-CsVsT2nd.js} +1 -1
  124. package/static/app/assets/{brain-BKDW1F0T.js → brain-vuppco5y.js} +1 -1
  125. package/static/app/assets/{button-CCB9Kuw7.js → button-1BP1PJZ3.js} +1 -1
  126. package/static/app/assets/{circle-pause-DNa8WHC5.js → circle-pause-5paeCOtq.js} +1 -1
  127. package/static/app/assets/{circle-play-8jmxn5mf.js → circle-play-By8mFYRV.js} +1 -1
  128. package/static/app/assets/{cpu-mxvF3V1I.js → cpu-DvUQxdKe.js} +1 -1
  129. package/static/app/assets/{download--SmCcx0p.js → download-CFqYG90a.js} +1 -1
  130. package/static/app/assets/{folder-open-D4Wy5roo.js → folder-open-CVSnp-Dp.js} +1 -1
  131. package/static/app/assets/{hard-drive-BwQcSPlS.js → hard-drive-Ckr4Vd6-.js} +1 -1
  132. package/static/app/assets/{index-DDV2YZwM.js → index--0zKF5Ow.js} +3 -3
  133. package/static/app/assets/index-CEkL4_M-.css +2 -0
  134. package/static/app/assets/{input-DFhSjmLS.js → input-D-JKquXU.js} +1 -1
  135. package/static/app/assets/{network-Bts7VO94.js → network-DB88FZ2T.js} +1 -1
  136. package/static/app/assets/{primitives-BuSMEJY8.js → primitives-D8U9OaIo.js} +1 -1
  137. package/static/app/assets/search-B8jsVTw_.js +1 -0
  138. package/static/app/assets/{shield-alert-BfTO6X_G.js → shield-alert-DnsvO79P.js} +1 -1
  139. package/static/app/assets/{textarea-BjctW1oh.js → textarea-4RchwbTa.js} +1 -1
  140. package/static/app/assets/{useFocusTrap-CE43-LrA.js → useFocusTrap-CFJjy7cS.js} +1 -1
  141. package/static/app/assets/{useQuery-Bv3ejLL5.js → useQuery-3Reu-A4a.js} +1 -1
  142. package/static/app/assets/{users-CsNqLZAj.js → users-BTxYksxa.js} +1 -1
  143. package/static/app/assets/{utils-KFFdVG_t.js → utils-D02Vf-fw.js} +1 -1
  144. package/static/app/assets/{workspace-wdCvdyPF.js → workspace-CSJNgX-C.js} +1 -1
  145. package/static/app/index.html +4 -4
  146. package/static/sw.js +1 -1
  147. package/static/app/assets/Library-CBia2Fvm.js +0 -1
  148. package/static/app/assets/index-CQWdDU3z.css +0 -2
  149. package/static/app/assets/search-DZzxhWaQ.js +0 -1
@@ -236,7 +236,7 @@ class MemoryService:
236
236
  elif kg_index:
237
237
  vector_total = kg_index.get("indexed") or kg_index.get("ready")
238
238
 
239
- sources = [
239
+ sources: List[Dict[str, Any]] = [
240
240
  {
241
241
  "id": "workspace", "type": "workspace", "label": "Workspace Memory",
242
242
  "count": len(ws_mem), "size_bytes": ws_bytes if ws_mem else 0,
@@ -272,7 +272,7 @@ class MemoryService:
272
272
  "detail": "Local embedding vector index." if kg_index else "Vector index unavailable.",
273
273
  },
274
274
  ]
275
- total_items = sum((s["count"] or 0) for s in sources)
275
+ total_items = sum(int(s["count"] or 0) for s in sources)
276
276
  total_bytes = ws_bytes + kg_bytes + conv_bytes
277
277
  healthy = sum(1 for s in sources if s["health"] == "ok")
278
278
  overall = "ok" if healthy >= 4 else "degraded" if healthy >= 1 else "unavailable"
@@ -747,7 +747,11 @@ class MemoryService:
747
747
  graph_count = int(sources.get("graph", {}).get("count") or 0)
748
748
  vector_count = int(sources.get("vector", {}).get("count") or 0)
749
749
  query = recall_query.strip() or self._latest_recall_query(user_email=user_email, workspace_id=workspace_id)
750
- recall = self.recall(query, user_email=user_email, workspace_id=workspace_id, limit=limit) if query else {"query": "", "results": [], "count": 0, "source": "live"}
750
+ recall: Dict[str, Any] = (
751
+ self.recall(query, user_email=user_email, workspace_id=workspace_id, limit=limit)
752
+ if query
753
+ else {"query": "", "results": [], "count": 0, "source": "live"}
754
+ )
751
755
  recall_items = [
752
756
  {
753
757
  "id": item.get("id"),
@@ -760,7 +764,7 @@ class MemoryService:
760
764
  "matched_terms": item.get("matched_terms") or [],
761
765
  "confidence": item.get("confidence") or "low",
762
766
  }
763
- for item in recall.get("results", [])[: max(1, min(limit, 8))]
767
+ for item in list(recall.get("results", []))[: max(1, min(limit, 8))]
764
768
  ]
765
769
  durable_items = workspace_count + conversation_count + graph_count
766
770
  # Capability and proof are deliberately separated. The brain is
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import re
15
15
  import sys
16
- from typing import Dict, List, Optional
16
+ from typing import Any, Dict, List, Optional
17
17
 
18
18
  from latticeai.services.model_capability_registry import (
19
19
  LOCAL_MLX_MODELS as _LOCAL_MLX_MODELS,
@@ -34,7 +34,7 @@ from latticeai.services.model_capability_registry import (
34
34
  get_verified_models as _get_verified_models,
35
35
  )
36
36
 
37
- ENGINE_INSTALLERS = {
37
+ ENGINE_INSTALLERS: Dict[str, Dict[str, Any]] = {
38
38
  "local_mlx": {
39
39
  "command": [sys.executable, "-m", "pip", "install", "--upgrade", "mlx-vlm>=0.6.3", "mlx-lm", "huggingface_hub[cli]"],
40
40
  "label": "Install MLX runtime",
@@ -89,10 +89,10 @@ ENGINE_INSTALLERS = {
89
89
  # user-facing ENGINE_MODEL_CATALOG below is then narrowed to the aggressive 5.2.0
90
90
  # policy (latest family generations only, no text-only/legacy weights) and the
91
91
  # engine-specific ids/sizes are normalised. See `_finalize_engine_catalog`.
92
- _RAW_ENGINE_MODEL_CATALOG: Dict[str, List[Dict[str, object]]] = _build_engine_model_catalog()
92
+ _RAW_ENGINE_MODEL_CATALOG: Dict[str, List[Dict[str, Any]]] = _build_engine_model_catalog()
93
93
  # Filled in at module end once the blocklist, alias map and family-version filter
94
94
  # are all defined; declared here so the public name exists for static readers.
95
- ENGINE_MODEL_CATALOG: Dict[str, List[Dict[str, object]]] = {}
95
+ ENGINE_MODEL_CATALOG: Dict[str, List[Dict[str, Any]]] = {}
96
96
 
97
97
  # Historical aliases preserved (used by _recommended_with_engine_options and resolution).
98
98
  # These can be enriched later from registry if needed; kept verbatim for safety.
@@ -173,7 +173,7 @@ def _version_tuple(raw: str) -> tuple[int, ...]:
173
173
  return tuple(int(part) for part in raw.split(".") if part.isdigit())
174
174
 
175
175
 
176
- def _model_family_version(model: Dict[str, object]) -> Optional[tuple[str, tuple[int, ...]]]:
176
+ def _model_family_version(model: Dict[str, Any]) -> Optional[tuple[str, tuple[int, ...]]]:
177
177
  text = " ".join(str(model.get(key) or "") for key in ("family", "name", "id"))
178
178
  for family, pattern in _VERSIONED_MODEL_PATTERNS:
179
179
  match = pattern.search(text)
@@ -184,9 +184,9 @@ def _model_family_version(model: Dict[str, object]) -> Optional[tuple[str, tuple
184
184
  return None
185
185
 
186
186
 
187
- def filter_lower_family_versions(models: List[Dict[str, object]]) -> List[Dict[str, object]]:
187
+ def filter_lower_family_versions(models: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
188
188
  max_versions: Dict[str, tuple[int, ...]] = {}
189
- detected: List[tuple[Dict[str, object], Optional[tuple[str, tuple[int, ...]]]]] = []
189
+ detected: List[tuple[Dict[str, Any], Optional[tuple[str, tuple[int, ...]]]]] = []
190
190
  for model in models:
191
191
  version_info = _model_family_version(model)
192
192
  detected.append((model, version_info))
@@ -214,12 +214,12 @@ _BLOCKED_CATALOG_FRAGMENTS = (
214
214
  )
215
215
 
216
216
 
217
- def _is_blocked_catalog_id(model: Dict[str, object]) -> bool:
217
+ def _is_blocked_catalog_id(model: Dict[str, Any]) -> bool:
218
218
  ident = str(model.get("id") or "").lower()
219
219
  return any(fragment in ident for fragment in _BLOCKED_CATALOG_FRAGMENTS)
220
220
 
221
221
 
222
- def _normalize_engine_entry(engine: str, model: Dict[str, object]) -> Dict[str, object]:
222
+ def _normalize_engine_entry(engine: str, model: Dict[str, Any]) -> Dict[str, Any]:
223
223
  """Apply historical engine-specific id + size conventions to a raw entry.
224
224
 
225
225
  * Non-MLX engines resolve to their canonical packaged id via
@@ -243,9 +243,9 @@ def _normalize_engine_entry(engine: str, model: Dict[str, object]) -> Dict[str,
243
243
 
244
244
 
245
245
  def _finalize_engine_catalog(
246
- raw: Dict[str, List[Dict[str, object]]],
247
- ) -> Dict[str, List[Dict[str, object]]]:
248
- final: Dict[str, List[Dict[str, object]]] = {}
246
+ raw: Dict[str, List[Dict[str, Any]]],
247
+ ) -> Dict[str, List[Dict[str, Any]]]:
248
+ final: Dict[str, List[Dict[str, Any]]] = {}
249
249
  for engine, models in raw.items():
250
250
  kept = [
251
251
  _normalize_engine_entry(engine, m)
@@ -33,7 +33,7 @@ from latticeai.services.process_audit import (
33
33
  )
34
34
 
35
35
 
36
- def _progress_payload(*args, **kwargs) -> Dict[str, object]:
36
+ def _progress_payload(*args, **kwargs) -> Dict[str, Any]:
37
37
  try:
38
38
  from latticeai.services.model_runtime import model_download_progress_payload
39
39
  except Exception:
@@ -63,7 +63,7 @@ def windows_binary_candidates(binary: str) -> List[Path]:
63
63
  local_appdata = os.environ.get("LOCALAPPDATA", "")
64
64
  program_files = os.environ.get("ProgramFiles", r"C:\Program Files")
65
65
  program_files_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
66
- candidates = {
66
+ candidates: Dict[str, List[Optional[Path]]] = {
67
67
  "ollama": [
68
68
  Path(local_appdata) / "Programs" / "Ollama" / "ollama.exe" if local_appdata else None,
69
69
  Path(program_files) / "Ollama" / "ollama.exe",
@@ -111,7 +111,7 @@ def _json_request(
111
111
  headers: Optional[Dict[str, str]] = None,
112
112
  payload: Optional[Dict[str, Any]] = None,
113
113
  timeout: float = 10.0,
114
- ) -> Dict[str, object]:
114
+ ) -> Dict[str, Any]:
115
115
  data = None
116
116
  req_headers = dict(headers or {})
117
117
  if payload is not None:
@@ -211,8 +211,6 @@ def get_openai_compatible_server_models(provider: str) -> List[str]:
211
211
  if provider == "lmstudio":
212
212
  models = []
213
213
  for item in get_lmstudio_models():
214
- if not isinstance(item, dict):
215
- continue
216
214
  key = str(item.get("key") or "").strip()
217
215
  loaded_instances = item.get("loaded_instances") or []
218
216
  if loaded_instances:
@@ -360,7 +358,7 @@ def ensure_llamacpp_server(model_name: str) -> None:
360
358
  raise ModelRuntimeError(status_code=500, detail="llama.cpp 서버가 모델을 자동 로드하지 못했습니다.")
361
359
 
362
360
 
363
- def pull_ollama_model_with_progress(model_name: str, progress_emit=None) -> Dict[str, object]:
361
+ def pull_ollama_model_with_progress(model_name: str, progress_emit=None) -> Dict[str, Any]:
364
362
  ollama = local_binary("ollama")
365
363
  if not ollama:
366
364
  raise ModelRuntimeError(status_code=400, detail="Ollama가 설치되지 않았습니다.")
@@ -447,7 +445,7 @@ def get_ollama_pulled_models() -> set:
447
445
  return set()
448
446
 
449
447
 
450
- def engine_support_status(engine: str) -> Dict[str, object]:
448
+ def engine_support_status(engine: str) -> Dict[str, Any]:
451
449
  if engine != "vllm":
452
450
  return {"supported": True, "reason": None}
453
451
  is_apple_silicon = sys.platform == "darwin" and platform.machine() == "arm64"
@@ -470,7 +468,7 @@ def _engine_install_command(
470
468
  if engine not in ENGINE_INSTALLERS:
471
469
  raise ModelRuntimeError(status_code=400, detail="지원하지 않는 엔진입니다.")
472
470
  installer = ENGINE_INSTALLERS[engine]
473
- required_binary = installer.get("requires_binary")
471
+ required_binary = str(installer.get("requires_binary") or "")
474
472
  if required_binary and shutil.which(required_binary) is None:
475
473
  raise ModelRuntimeError(status_code=400, detail=f"{required_binary}가 설치되어 있지 않아 자동 설치할 수 없습니다.")
476
474
  command = list(installer["command"])
@@ -528,16 +526,16 @@ def install_engine(
528
526
  },
529
527
  ) from exc
530
528
 
531
- run_kwargs = {
532
- "cwd": cwd,
533
- "capture_output": True,
534
- "text": True,
535
- "timeout": 900,
536
- "check": False,
537
- }
538
529
  try:
539
530
  append_process_audit_event("engine_install", plan=plan, status="started")
540
- completed = subprocess.run(command, **run_kwargs)
531
+ completed = subprocess.run(
532
+ command,
533
+ cwd=cwd,
534
+ capture_output=True,
535
+ text=True,
536
+ timeout=900,
537
+ check=False,
538
+ )
541
539
  except subprocess.TimeoutExpired:
542
540
  append_process_audit_event("engine_install", plan=plan, status="timeout")
543
541
  raise ModelRuntimeError(status_code=408, detail="엔진 설치 시간이 초과되었습니다.")
@@ -603,7 +601,7 @@ async def _smoke_test_loaded_model(
603
601
  *,
604
602
  api_key_override: Optional[str] = None,
605
603
  model_router: Any = None,
606
- ) -> Dict[str, object]:
604
+ ) -> Dict[str, Any]:
607
605
  """로드 직후 짧은 채팅 테스트를 돌려 ready_to_chat 여부를 판정한다.
608
606
 
609
607
  Cloud models are skipped to avoid cost.
@@ -231,7 +231,7 @@ async def prepare_and_load_model_stream(
231
231
  if compatibility.get("supported") is False:
232
232
  raise ModelRuntimeError(status_code=400, detail=compatibility)
233
233
 
234
- work_queue: "queue.Queue[Dict[str, object]]" = queue.Queue()
234
+ work_queue: "queue.Queue[Dict[str, Any]]" = queue.Queue()
235
235
  work_result: Dict[str, object] = {}
236
236
 
237
237
  def emit_progress(payload: Dict[str, object]) -> None:
@@ -269,10 +269,10 @@ def _json_request(
269
269
  url: str,
270
270
  *,
271
271
  method: str = "GET",
272
- payload: Optional[Dict[str, object]] = None,
272
+ payload: Optional[Dict[str, Any]] = None,
273
273
  headers: Optional[Dict[str, str]] = None,
274
274
  timeout: float = 10.0,
275
- ) -> Dict[str, object]:
275
+ ) -> Dict[str, Any]:
276
276
  data = None
277
277
  req_headers = dict(headers or {})
278
278
  if payload is not None:
@@ -299,12 +299,12 @@ def ensure_lmstudio_server() -> None:
299
299
  return _ensure_lmstudio_server()
300
300
 
301
301
 
302
- _LMSTUDIO_MODELS_CACHE: List[Dict[str, object]] = []
302
+ _LMSTUDIO_MODELS_CACHE: List[Dict[str, Any]] = []
303
303
  _LMSTUDIO_MODELS_CACHE_TS: float = 0.0
304
304
  _LMSTUDIO_MODELS_CACHE_TTL: float = 10.0
305
305
 
306
306
 
307
- def get_lmstudio_models(*, force: bool = False) -> List[Dict[str, object]]:
307
+ def get_lmstudio_models(*, force: bool = False) -> List[Dict[str, Any]]:
308
308
  global _LMSTUDIO_MODELS_CACHE, _LMSTUDIO_MODELS_CACHE_TS
309
309
  if not force and time.monotonic() - _LMSTUDIO_MODELS_CACHE_TS < _LMSTUDIO_MODELS_CACHE_TTL:
310
310
  return _LMSTUDIO_MODELS_CACHE
@@ -335,15 +335,13 @@ def _lmstudio_candidate_keys(model_name: str) -> List[str]:
335
335
  return list(dict.fromkeys(candidates))
336
336
 
337
337
 
338
- def _find_lmstudio_model_key(model_name: str, models: List[Dict[str, object]]) -> Optional[str]:
338
+ def _find_lmstudio_model_key(model_name: str, models: List[Dict[str, Any]]) -> Optional[str]:
339
339
  if not models:
340
340
  return None
341
341
  candidate_keys = _lmstudio_candidate_keys(model_name)
342
342
  exact = []
343
343
  fuzzy = []
344
344
  for item in models:
345
- if not isinstance(item, dict):
346
- continue
347
345
  key = str(item.get("key") or "").strip()
348
346
  display_name = str(item.get("display_name") or "").strip()
349
347
  haystacks = [key.lower(), display_name.lower()]
@@ -352,10 +350,10 @@ def _find_lmstudio_model_key(model_name: str, models: List[Dict[str, object]]) -
352
350
  continue
353
351
  if any(token and token in hay for token in candidate_keys for hay in haystacks):
354
352
  fuzzy.append(key)
355
- return (exact or fuzzy or [None])[0]
353
+ return next(iter(exact or fuzzy), None)
356
354
 
357
355
 
358
- def ensure_lmstudio_model(model_name: str) -> Dict[str, object]:
356
+ def ensure_lmstudio_model(model_name: str) -> Dict[str, Any]:
359
357
  ensure_lmstudio_server()
360
358
  auth_header = {"Authorization": f"Bearer {os.getenv('LMSTUDIO_API_KEY') or 'lmstudio'}"}
361
359
  models = get_lmstudio_models()
@@ -430,7 +428,7 @@ def ensure_lmstudio_model(model_name: str) -> Dict[str, object]:
430
428
  "cached": False,
431
429
  }
432
430
 
433
- def engine_support_status(engine: str) -> Dict[str, object]:
431
+ def engine_support_status(engine: str) -> Dict[str, Any]:
434
432
  return _engine_support_status(engine)
435
433
 
436
434
  def hf_model_ready(repo_id: str, provider: str = "local_mlx") -> bool:
@@ -467,8 +465,8 @@ def model_download_progress_payload(
467
465
  eta_seconds: Optional[float] = None,
468
466
  file: Optional[str] = None,
469
467
  indeterminate: bool = False,
470
- ) -> Dict[str, object]:
471
- payload: Dict[str, object] = {
468
+ ) -> Dict[str, Any]:
469
+ payload: Dict[str, Any] = {
472
470
  "stage": stage,
473
471
  "message": message,
474
472
  "indeterminate": indeterminate,
@@ -496,7 +494,7 @@ def estimate_eta_seconds(started_at: float, percent: Optional[float]) -> Optiona
496
494
  return elapsed * (100.0 - percent) / percent
497
495
 
498
496
 
499
- def hf_repo_files_with_sizes(repo_id: str) -> List[Dict[str, object]]:
497
+ def hf_repo_files_with_sizes(repo_id: str) -> List[Dict[str, Any]]:
500
498
  from huggingface_hub import HfApi
501
499
 
502
500
  api = HfApi()
@@ -522,7 +520,7 @@ def download_hf_model(
522
520
  repo_id: str,
523
521
  provider: str = "local_mlx",
524
522
  progress_emit=None,
525
- ) -> Dict[str, object]:
523
+ ) -> Dict[str, Any]:
526
524
  if importlib.util.find_spec("huggingface_hub") is None:
527
525
  raise ModelRuntimeError(status_code=400, detail="huggingface_hub가 없습니다. 먼저 MLX runtime 설치를 진행해 주세요.")
528
526
 
@@ -695,7 +693,7 @@ def download_hf_model(
695
693
  return {"model": repo_id, "path": str(target_dir), "cached": False}
696
694
 
697
695
 
698
- def pull_ollama_model_with_progress(model_name: str, progress_emit=None) -> Dict[str, object]:
696
+ def pull_ollama_model_with_progress(model_name: str, progress_emit=None) -> Dict[str, Any]:
699
697
  return _pull_ollama_model_with_progress(model_name, progress_emit)
700
698
 
701
699
 
@@ -727,7 +725,7 @@ def _safe_engine_install_plan(
727
725
  engine: str,
728
726
  *,
729
727
  base_dir: Path,
730
- ) -> Optional[Dict[str, object]]:
728
+ ) -> Optional[Dict[str, Any]]:
731
729
  try:
732
730
  return _engine_install_plan(engine, base_dir=base_dir)
733
731
  except Exception:
@@ -760,7 +758,7 @@ def engine_status(
760
758
  r = state.router
761
759
  verify_cache = cloud_verify_cache or {}
762
760
  cloud_models = r.detected_cloud_models() if r else []
763
- cloud_by_provider = {}
761
+ cloud_by_provider: Dict[str, List[Dict[str, Any]]] = {}
764
762
  for model in cloud_models:
765
763
  cloud_by_provider.setdefault(model["provider"], []).append(model)
766
764
 
@@ -787,10 +785,8 @@ def engine_status(
787
785
 
788
786
  lmstudio_models = []
789
787
  downloaded_lmstudio = get_lmstudio_models()
790
- downloaded_by_key = {}
788
+ downloaded_by_key: Dict[str, Dict[str, Any]] = {}
791
789
  for item in downloaded_lmstudio:
792
- if not isinstance(item, dict):
793
- continue
794
790
  key = str(item.get("key") or "").strip()
795
791
  if not key:
796
792
  continue
@@ -823,7 +819,7 @@ def engine_status(
823
819
  llamacpp_models.append({**m, "pulled": hf_model_ready(repo_id, "llamacpp")})
824
820
  llamacpp_models = filter_lower_family_versions(llamacpp_models)
825
821
 
826
- local_server_specs = [
822
+ local_server_specs: List[Dict[str, Any]] = [
827
823
  {
828
824
  "id": "vllm",
829
825
  "name": "vLLM",
@@ -906,7 +902,7 @@ def engine_status(
906
902
  env_key = next((item.get("requires") for item in cloud_by_provider.get(provider, []) if item.get("requires")), None)
907
903
  provider_models = []
908
904
  for model in cloud_by_provider.get(provider, []):
909
- cache = verify_cache.get(model.get("id"))
905
+ cache = verify_cache.get(str(model.get("id") or ""))
910
906
  provider_models.append({
911
907
  **model,
912
908
  "verified": cache.get("ok") if cache else None,
@@ -1009,7 +1005,7 @@ def normalize_local_model_request(model_id: str, engine: Optional[str] = None) -
1009
1005
  return model_id
1010
1006
 
1011
1007
 
1012
- def ensure_engine_ready(engine: str, *, state: ModelRuntimeState) -> Dict[str, object]:
1008
+ def ensure_engine_ready(engine: str, *, state: ModelRuntimeState) -> Dict[str, Any]:
1013
1009
  engine = "local_mlx" if engine == "mlx" else engine
1014
1010
  if engine not in ENGINE_INSTALLERS and engine not in OPENAI_COMPATIBLE_PROVIDERS:
1015
1011
  raise ModelRuntimeError(status_code=400, detail=f"지원하지 않는 엔진입니다: {engine}")
@@ -1065,7 +1061,7 @@ async def _smoke_test_loaded_model(
1065
1061
  *,
1066
1062
  api_key_override: Optional[str] = None,
1067
1063
  state: ModelRuntimeState,
1068
- ) -> Dict[str, object]:
1064
+ ) -> Dict[str, Any]:
1069
1065
  # Delegated to model_engines for server decomp
1070
1066
  from .model_engines import _smoke_test_loaded_model as _impl_smoke
1071
1067
  return await _impl_smoke(
@@ -1085,7 +1081,7 @@ async def prepare_and_load_model(
1085
1081
  allow_download: bool = False,
1086
1082
  *,
1087
1083
  state: ModelRuntimeState,
1088
- ) -> Dict[str, object]:
1084
+ ) -> Dict[str, Any]:
1089
1085
  from .model_loading import prepare_and_load_model as _impl
1090
1086
 
1091
1087
  return await _impl(
@@ -1100,7 +1096,7 @@ async def prepare_and_load_model(
1100
1096
  )
1101
1097
 
1102
1098
 
1103
- def sse_event(event: str, data: Dict[str, object]) -> str:
1099
+ def sse_event(event: str, data: Dict[str, Any]) -> str:
1104
1100
  return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
1105
1101
 
1106
1102
 
@@ -1128,7 +1124,7 @@ async def prepare_and_load_model_stream(
1128
1124
 
1129
1125
  CLOUD_VERIFY_TTL_SECONDS = 600
1130
1126
 
1131
- async def _probe_cloud_model(model_ref: str) -> Dict[str, object]:
1127
+ async def _probe_cloud_model(model_ref: str) -> Dict[str, Any]:
1132
1128
  provider, model_name = parse_model_ref(model_ref)
1133
1129
  config = OPENAI_COMPATIBLE_PROVIDERS.get(provider)
1134
1130
  if not config:
@@ -1140,12 +1136,14 @@ async def _probe_cloud_model(model_ref: str) -> Dict[str, object]:
1140
1136
 
1141
1137
  base_url = os.getenv(config.get("base_url_env", "")) if config.get("base_url_env") else None
1142
1138
  base_url = base_url or config.get("base_url")
1143
- client_kwargs = {"api_key": api_key}
1144
- if base_url:
1145
- client_kwargs["base_url"] = base_url
1146
-
1147
1139
  try:
1148
- client = AsyncOpenAI(**client_kwargs)
1140
+ # base_url is passed only when configured: an explicit None is not
1141
+ # the same as omitting the argument.
1142
+ client = (
1143
+ AsyncOpenAI(api_key=api_key, base_url=base_url)
1144
+ if base_url
1145
+ else AsyncOpenAI(api_key=api_key)
1146
+ )
1149
1147
  await asyncio.wait_for(
1150
1148
  client.chat.completions.create(
1151
1149
  model=model_name,
@@ -1246,7 +1244,7 @@ class ModelRuntimeService:
1246
1244
  adapter_path: Optional[str] = None,
1247
1245
  draft_model_id: Optional[str] = None,
1248
1246
  allow_download: bool = False,
1249
- ) -> Dict[str, object]:
1247
+ ) -> Dict[str, Any]:
1250
1248
  return await prepare_and_load_model(
1251
1249
  model_id,
1252
1250
  request,
@@ -45,14 +45,20 @@ class MultimodalTurnResult:
45
45
 
46
46
 
47
47
  class MultimodalAdapter(Protocol):
48
- async def stream_media(
48
+ # See CloudLLMAdapter.stream: an async generator is declared with `def`.
49
+ def stream_media(
49
50
  self,
50
51
  *,
51
52
  prompt: str,
52
53
  context: str,
53
54
  model: Optional[str] = None,
54
- ) -> AsyncIterator[Dict[str, Any]]:
55
- """Yield progress events, then a final event with media_urls."""
55
+ ) -> AsyncIterator[Any]:
56
+ """Yield progress events, then a final event with media_urls.
57
+
58
+ Typed as ``Any`` rather than ``Dict[str, Any]`` because adapters are
59
+ supplied by callers: the bridge validates each event's shape rather
60
+ than trusting the annotation.
61
+ """
56
62
  ...
57
63
 
58
64
 
@@ -112,7 +118,7 @@ class MultimodalStreamingBridge:
112
118
  answer_text="\n".join(notes),
113
119
  sent_node_ids=list(minimal.node_ids),
114
120
  provider=getattr(self._adapter, "provider_name", "multimodal"),
115
- model=model or getattr(self._adapter, "default_model", ""),
121
+ model=str(model or getattr(self._adapter, "default_model", "")),
116
122
  )
117
123
 
118
124
 
@@ -51,10 +51,11 @@ class OpenAICompatibleAdapter:
51
51
  raise RuntimeError(
52
52
  "openai package is required for the cloud streaming adapter"
53
53
  ) from exc
54
- kwargs = {"api_key": self.api_key}
54
+ # base_url is only passed when configured: AsyncOpenAI treats an
55
+ # explicit None differently from an omitted argument.
55
56
  if self.base_url:
56
- kwargs["base_url"] = self.base_url
57
- return AsyncOpenAI(**kwargs)
57
+ return AsyncOpenAI(api_key=self.api_key, base_url=self.base_url)
58
+ return AsyncOpenAI(api_key=self.api_key)
58
59
 
59
60
  async def stream(
60
61
  self,
@@ -18,7 +18,7 @@ from typing import Any, Dict, List
18
18
 
19
19
  from latticeai.services.architecture_readiness import architecture_readiness
20
20
 
21
- PRODUCT_VERSION_TARGET = "10.2.0"
21
+ PRODUCT_VERSION_TARGET = "10.4.0"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
@@ -29,6 +29,15 @@ class _RunHandle:
29
29
  cancel_requested: bool = False
30
30
  started: bool = False
31
31
 
32
+ def is_cancelled(self) -> bool:
33
+ """Read the flag through a call.
34
+
35
+ `cancel_requested` is set by a *different* task (``cancel``), so a
36
+ reader must not be narrowed by an earlier check in its own frame:
37
+ the value can change between the guard and the poll.
38
+ """
39
+ return self.cancel_requested
40
+
32
41
 
33
42
  class RunExecutor:
34
43
  """Async task manager for persisted agent/workflow executions."""
@@ -119,9 +128,9 @@ class RunExecutor:
119
128
  roles=roles,
120
129
  inputs=inputs,
121
130
  max_retries=max_retries,
122
- cancel_requested=lambda: handle.cancel_requested,
131
+ cancel_requested=handle.is_cancelled,
123
132
  )
124
- if handle.cancel_requested and (payload.get("run") or {}).get("status") != "cancelled":
133
+ if handle.is_cancelled() and (payload.get("run") or {}).get("status") != "cancelled":
125
134
  self._cancel_agent_record(run_id, handle.scope, "cancelled after the final result was persisted")
126
135
  else:
127
136
  self._results[run_id] = payload
@@ -232,7 +241,7 @@ class RunExecutor:
232
241
  started_at=run.get("started_at") or _now(),
233
242
  )
234
243
  result = await asyncio.to_thread(self._execute_workflow_sync, workflow, user_email, handle.scope, inputs)
235
- if handle.cancel_requested:
244
+ if handle.is_cancelled():
236
245
  self._cancel_workflow_record(run_id, handle.scope, "cancelled after the current synchronous step completed")
237
246
  return
238
247
  pause = (
@@ -330,7 +330,7 @@ class SearchService:
330
330
  by_id[node_id] = current
331
331
  current["score"] = max(float(current["score"]), score)
332
332
  current["source_scores"]["graph"] = max(float(current["source_scores"]["graph"]), score)
333
- context = {"reason": reason}
333
+ context: Dict[str, Any] = {"reason": reason}
334
334
  if edge:
335
335
  context["relationship"] = {
336
336
  "id": edge.get("id"),
@@ -71,7 +71,7 @@ class TriggerService:
71
71
  try:
72
72
  self._tz = ZoneInfo(self._tz_name)
73
73
  except Exception:
74
- self._tz = ZoneInfo("UTC") if ZoneInfo else None
74
+ self._tz = ZoneInfo("UTC")
75
75
 
76
76
  # ── durable state ──────────────────────────────────────────────────────
77
77
  def _load_state(self) -> Dict[str, Any]:
@@ -298,7 +298,7 @@ def _detect_cpu_details(prof_os: str) -> Tuple[str, int, int, List[str]]:
298
298
  quiet()
299
299
  try:
300
300
  import ctypes
301
- kernel32 = ctypes.windll.kernel32
301
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] # Windows-only
302
302
  feature_map = {6: "sse", 10: "sse2", 13: "sse3", 19: "neon", 28: "rdrand"}
303
303
  flags.extend(name for code, name in feature_map.items() if kernel32.IsProcessorFeaturePresent(code))
304
304
  except Exception:
@@ -352,7 +352,7 @@ def _detect_cpu() -> Dict[str, Any]:
352
352
  quiet()
353
353
  try:
354
354
  import ctypes
355
- kernel32 = ctypes.windll.kernel32
355
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] # Windows-only
356
356
  feature_map = {
357
357
  6: "sse",
358
358
  10: "sse2",
@@ -836,9 +836,18 @@ def get_recommendations(env: Dict[str, Any]) -> Dict[str, Any]:
836
836
  if cid == "homebrew":
837
837
  action = {"type": "url", "url": OFFICIAL_DOWNLOADS["homebrew"], "binary": "brew"}
838
838
  elif tools.get("brew") and brew_pkg:
839
- action = {"type": "brew", "package": brew_pkg, "binary": binary, "official_url": OFFICIAL_DOWNLOADS.get(cid)}
839
+ action = {
840
+ "type": "brew",
841
+ "package": brew_pkg,
842
+ "binary": binary or "",
843
+ "official_url": OFFICIAL_DOWNLOADS.get(cid, ""),
844
+ }
840
845
  else:
841
- action = {"type": "url", "url": OFFICIAL_DOWNLOADS.get(cid, ""), "binary": binary}
846
+ action = {
847
+ "type": "url",
848
+ "url": OFFICIAL_DOWNLOADS.get(cid, ""),
849
+ "binary": binary or "",
850
+ }
842
851
  components.append({
843
852
  "id": f"component_{cid}", "name": name,
844
853
  "subtitle": subtitle, "status": "available",
@@ -17,7 +17,9 @@ _PLATFORM = platform.system()
17
17
 
18
18
  # ── Computer Use ──────────────────────────────────────────────────────────────
19
19
  _CU_AVAILABLE = False
20
- _pyautogui = None
20
+ # Optional dependency: None until _init_computer_use() imports it, and the
21
+ # _CU_AVAILABLE flag is what every call site checks.
22
+ _pyautogui: Any = None
21
23
 
22
24
  def _init_computer_use():
23
25
  global _CU_AVAILABLE, _pyautogui
@@ -51,7 +51,7 @@ def create_docx(title: str, body, filename: str = "document.docx") -> Dict[str,
51
51
  text = block.strip()
52
52
  if text:
53
53
  document.add_paragraph(text)
54
- document.save(target)
54
+ document.save(str(target))
55
55
  return {"path": _relative(target), "bytes": target.stat().st_size}
56
56
 
57
57
 
@@ -107,7 +107,7 @@ def create_pptx(title: str, slides: List[Dict[str, Any]], filename: str = "prese
107
107
  paragraph.text = str(bullet)
108
108
  paragraph.level = 0
109
109
 
110
- presentation.save(target)
110
+ presentation.save(str(target))
111
111
  return {"path": _relative(target), "slides": len(presentation.slides), "bytes": target.stat().st_size}
112
112
 
113
113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "10.2.0",
3
+ "version": "10.4.0",
4
4
  "description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -61,7 +61,8 @@
61
61
  "test:vscode-extension": "cd vscode-extension && npm run build && cd .. && node --test tests/vscode-extension.test.cjs",
62
62
  "check:i18n-namespaces": "node scripts/check_i18n_namespace_coverage.mjs",
63
63
  "typecheck:python": "node scripts/run_python.mjs -m mypy",
64
- "test:coverage": "node scripts/run_python.mjs -m pytest tests/ -q --cov --cov-report=term:skip-covered"
64
+ "test:coverage": "node scripts/run_python.mjs -m pytest tests/ -q --cov --cov-report=term:skip-covered",
65
+ "test:frontend:coverage": "vitest run --coverage"
65
66
  },
66
67
  "keywords": [
67
68
  "ltcai",
@@ -137,6 +138,7 @@
137
138
  "@types/react": "^19.2.17",
138
139
  "@types/react-dom": "^19.2.3",
139
140
  "@vitejs/plugin-react": "^6.0.2",
141
+ "@vitest/coverage-v8": "^4.1.10",
140
142
  "autoprefixer": "^10.5.0",
141
143
  "class-variance-authority": "^0.7.1",
142
144
  "clsx": "^2.1.1",