ltcai 8.9.0 → 9.0.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 (89) hide show
  1. package/README.md +28 -39
  2. package/auto_setup.py +11 -62
  3. package/docs/CHANGELOG.md +76 -237
  4. package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
  5. package/docs/DEVELOPMENT.md +8 -8
  6. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  7. package/docs/ONBOARDING.md +2 -2
  8. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +2 -2
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/archive.py +4 -9
  14. package/lattice_brain/embeddings.py +38 -2
  15. package/lattice_brain/graph/_kg_common.py +27 -462
  16. package/lattice_brain/graph/_kg_constants.py +243 -0
  17. package/lattice_brain/graph/_kg_fsutil.py +297 -0
  18. package/lattice_brain/graph/discovery.py +0 -948
  19. package/lattice_brain/graph/discovery_index.py +972 -0
  20. package/lattice_brain/graph/retrieval.py +0 -570
  21. package/lattice_brain/graph/retrieval_docgen.py +210 -0
  22. package/lattice_brain/graph/retrieval_vector.py +460 -0
  23. package/lattice_brain/graph/store.py +6 -0
  24. package/lattice_brain/ingestion.py +68 -0
  25. package/lattice_brain/portability.py +1 -9
  26. package/lattice_brain/quality.py +98 -4
  27. package/lattice_brain/runtime/agent_runtime.py +166 -0
  28. package/lattice_brain/runtime/multi_agent.py +1 -1
  29. package/lattice_brain/utils.py +28 -0
  30. package/latticeai/__init__.py +1 -1
  31. package/latticeai/api/chat.py +340 -407
  32. package/latticeai/api/chat_helpers.py +227 -0
  33. package/latticeai/api/computer_use.py +149 -31
  34. package/latticeai/api/marketplace.py +11 -0
  35. package/latticeai/api/permissions.py +3 -3
  36. package/latticeai/api/tools.py +1 -0
  37. package/latticeai/app_factory.py +82 -360
  38. package/latticeai/core/io_utils.py +37 -0
  39. package/latticeai/core/legacy_compatibility.py +1 -1
  40. package/latticeai/core/local_embeddings.py +2 -4
  41. package/latticeai/core/marketplace.py +33 -2
  42. package/latticeai/core/mcp_catalog.py +450 -0
  43. package/latticeai/core/mcp_registry.py +2 -441
  44. package/latticeai/core/users.py +4 -9
  45. package/latticeai/core/workspace_os.py +1 -1
  46. package/latticeai/core/workspace_os_utils.py +3 -17
  47. package/latticeai/integrations/telegram_bot.py +7 -2
  48. package/latticeai/models/model_providers.py +111 -0
  49. package/latticeai/models/router.py +58 -136
  50. package/latticeai/runtime/audit_runtime.py +27 -16
  51. package/latticeai/runtime/automation_runtime.py +9 -0
  52. package/latticeai/runtime/history_runtime.py +163 -0
  53. package/latticeai/runtime/namespace_runtime.py +173 -0
  54. package/latticeai/runtime/network_config_runtime.py +56 -0
  55. package/latticeai/runtime/sso_config_runtime.py +128 -0
  56. package/latticeai/runtime/user_key_runtime.py +106 -0
  57. package/latticeai/services/architecture_readiness.py +2 -2
  58. package/latticeai/services/memory_service.py +213 -0
  59. package/latticeai/services/platform_runtime.py +9 -1
  60. package/latticeai/services/product_readiness.py +11 -11
  61. package/latticeai/services/review_queue.py +64 -11
  62. package/latticeai/services/run_executor.py +21 -0
  63. package/latticeai/services/setup_detection.py +80 -0
  64. package/latticeai/services/tool_dispatch.py +1 -1
  65. package/package.json +1 -1
  66. package/setup_wizard.py +11 -55
  67. package/src-tauri/Cargo.lock +1 -1
  68. package/src-tauri/Cargo.toml +1 -1
  69. package/src-tauri/tauri.conf.json +1 -1
  70. package/static/app/asset-manifest.json +11 -11
  71. package/static/app/assets/{Act-fZokUnC0.js → Act-21lIXx2E.js} +1 -1
  72. package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
  73. package/static/app/assets/{Capture-D5KV3Cu7.js → Capture-BA7Z2Q1u.js} +1 -1
  74. package/static/app/assets/{Library-C9kyFkSt.js → Library-bFMtyni3.js} +1 -1
  75. package/static/app/assets/System-K6krGCqn.js +1 -0
  76. package/static/app/assets/index-C4R3ws30.js +17 -0
  77. package/static/app/assets/index-ChSeOB02.css +2 -0
  78. package/static/app/assets/primitives-sQU3it5I.js +1 -0
  79. package/static/app/assets/{textarea-CD8UNKIy.js → textarea-DK3Fd_lR.js} +1 -1
  80. package/static/app/index.html +2 -2
  81. package/static/css/tokens.css +4 -2
  82. package/static/sw.js +1 -1
  83. package/tools/local_files.py +6 -0
  84. package/latticeai/runtime/sso_runtime.py +0 -52
  85. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  86. package/static/app/assets/System-VbChmX7r.js +0 -1
  87. package/static/app/assets/index-DCh5AoXt.css +0 -2
  88. package/static/app/assets/index-DPdcPoF0.js +0 -17
  89. package/static/app/assets/primitives-DFeanEV6.js +0 -1
@@ -34,18 +34,21 @@ from latticeai.runtime.hooks_runtime import (
34
34
  bind_trigger_hook_runner,
35
35
  build_hooks_runtime,
36
36
  )
37
+ from latticeai.runtime.history_runtime import build_history_query_runtime
37
38
  from latticeai.runtime.lifespan_runtime import build_lifespan_runtime
39
+ from latticeai.runtime.network_config_runtime import build_vpc_runtime
38
40
  from latticeai.runtime.model_wiring import (
39
41
  configure_model_runtime_from_context,
40
42
  register_model_runtime_routers,
41
43
  )
44
+ from latticeai.runtime.namespace_runtime import RuntimeBundle, build_runtime_namespace
42
45
  from latticeai.runtime.platform_services_runtime import (
43
46
  build_brain_network,
44
47
  )
45
48
  from latticeai.runtime.platform_runtime_wiring import build_platform_automation_runtime
46
49
  from latticeai.runtime.persistence_runtime import build_persistence_runtime
47
50
  from latticeai.runtime.review_wiring import build_review_run_now_runner
48
- from latticeai.runtime.sso_runtime import build_sso_runtime
51
+ from latticeai.runtime.sso_config_runtime import build_sso_config_runtime
49
52
  from latticeai.runtime.audit_runtime import build_audit_runtime
50
53
  from latticeai.runtime.router_registration import (
51
54
  build_auth_admin_security_router_bundle,
@@ -58,6 +61,7 @@ from latticeai.runtime.router_registration import (
58
61
  )
59
62
  from latticeai.runtime.security_runtime import build_security_runtime
60
63
  from latticeai.runtime.tail_wiring import register_tail_runtime_routers
64
+ from latticeai.runtime.user_key_runtime import build_user_key_runtime
61
65
  from latticeai.runtime.web_runtime import build_web_runtime
62
66
 
63
67
  if TYPE_CHECKING: # imports for annotations only — keep module import light
@@ -73,14 +77,9 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
73
77
  deliberately *inside* this function so that importing the module performs
74
78
  no GPU init, no singleton construction, and no filesystem writes.
75
79
  """
76
- import hashlib
77
- import json
78
80
  import logging
79
81
  import os
80
- import re
81
- import secrets
82
82
  import threading
83
- import time
84
83
  from pathlib import Path
85
84
 
86
85
  try:
@@ -255,11 +254,8 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
255
254
  SSO_REDIRECT_URI = _security_runtime["SSO_REDIRECT_URI"]
256
255
  SSO_PROVIDER_NAME = _security_runtime["SSO_PROVIDER_NAME"]
257
256
 
258
- # SSO cache + discovery built after get_sso_settings is defined (see below)
259
- _sso_discovery_cache = None
260
- _sso_discovery_cache_url = ""
261
- _sso_states: Dict[str, float] = {}
262
- _get_sso_discovery = None # filled after def below
257
+ # SSO config + discovery seam is built below, after SSO_FILE is known
258
+ # (latticeai.runtime.sso_config_runtime).
263
259
 
264
260
  # ── Password hashing — used directly from latticeai.core.security ──────────────
265
261
  # (hash_password / verify_password are imported above; no local wrapper needed)
@@ -434,89 +430,31 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
434
430
  redirect_uri: Optional[str] = None
435
431
  scopes: Optional[str] = None
436
432
 
437
- def _sso_env_defaults() -> Dict[str, object]:
438
- return {
439
- "enabled": bool(SSO_DISCOVERY_URL and SSO_CLIENT_ID and SSO_CLIENT_SECRET),
440
- "provider_name": SSO_PROVIDER_NAME,
441
- "discovery_url": SSO_DISCOVERY_URL,
442
- "client_id": SSO_CLIENT_ID,
443
- "client_secret": SSO_CLIENT_SECRET,
444
- "redirect_uri": SSO_REDIRECT_URI,
445
- "scopes": "openid email profile",
446
- }
447
-
448
- def load_sso_config() -> Dict[str, object]:
449
- config = _sso_env_defaults()
450
- if SSO_FILE.exists():
451
- try:
452
- data = json.loads(SSO_FILE.read_text(encoding="utf-8"))
453
- if isinstance(data, dict):
454
- config.update({k: v for k, v in data.items() if v is not None})
455
- except Exception as e:
456
- logging.warning("load_sso_config failed (using env/defaults): %s", e)
457
- config["provider_name"] = str(config.get("provider_name") or "SSO")
458
- config["discovery_url"] = str(config.get("discovery_url") or "")
459
- config["client_id"] = str(config.get("client_id") or "")
460
- config["client_secret"] = str(config.get("client_secret") or "")
461
- config["redirect_uri"] = str(config.get("redirect_uri") or SSO_REDIRECT_URI)
462
- config["scopes"] = str(config.get("scopes") or "openid email profile")
463
- config["enabled"] = bool(config.get("enabled")) and bool(
464
- config["discovery_url"] and config["client_id"] and config["client_secret"]
465
- )
466
- return config
467
-
468
- def get_sso_settings() -> Dict[str, object]:
469
- return load_sso_config()
470
-
471
- # Build SSO runtime (cache + helper) now that get_sso_settings exists in scope.
472
- if _get_sso_discovery is None:
473
- _sso = build_sso_runtime(
474
- get_sso_settings=get_sso_settings,
475
- logging=logging,
476
- )
477
- _sso_discovery_cache = _sso["_sso_discovery_cache"]
478
- _sso_discovery_cache_url = _sso["_sso_discovery_cache_url"]
479
- _sso_states = _sso["_sso_states"]
480
- _get_sso_discovery = _sso["_get_sso_discovery"]
481
-
482
- def public_sso_config(config: Optional[Dict[str, object]] = None) -> Dict[str, object]:
483
- cfg = config or get_sso_settings()
484
- return {
485
- "enabled": bool(cfg.get("enabled")),
486
- "provider_name": cfg.get("provider_name") or "",
487
- "discovery_url": cfg.get("discovery_url") or "",
488
- "client_id": cfg.get("client_id") or "",
489
- "redirect_uri": cfg.get("redirect_uri") or SSO_REDIRECT_URI,
490
- "scopes": cfg.get("scopes") or "openid email profile",
491
- "secret_configured": bool(cfg.get("client_secret")),
492
- }
493
-
494
- def save_sso_config(update: Dict[str, object]) -> Dict[str, object]:
495
- nonlocal _sso_discovery_cache, _sso_discovery_cache_url
496
- current = load_sso_config()
497
- if update.get("client_secret") == "":
498
- update.pop("client_secret", None)
499
- current.update({k: v for k, v in update.items() if v is not None})
500
- current["enabled"] = bool(current.get("enabled")) and bool(
501
- current.get("discovery_url") and current.get("client_id") and current.get("client_secret")
502
- )
503
- SSO_FILE.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8")
504
- _sso_discovery_cache = None
505
- _sso_discovery_cache_url = ""
506
- return current
433
+ # SSO config + OIDC discovery seam. One closure owns config load/save and the
434
+ # discovery cache, so save_sso_config now actually invalidates discovery.
435
+ _sso_runtime = build_sso_config_runtime(
436
+ sso_file=SSO_FILE,
437
+ discovery_url=SSO_DISCOVERY_URL,
438
+ client_id=SSO_CLIENT_ID,
439
+ client_secret=SSO_CLIENT_SECRET,
440
+ redirect_uri=SSO_REDIRECT_URI,
441
+ provider_name=SSO_PROVIDER_NAME,
442
+ logging=logging,
443
+ )
444
+ _sso_env_defaults = _sso_runtime["_sso_env_defaults"]
445
+ load_sso_config = _sso_runtime["load_sso_config"]
446
+ get_sso_settings = _sso_runtime["get_sso_settings"]
447
+ public_sso_config = _sso_runtime["public_sso_config"]
448
+ save_sso_config = _sso_runtime["save_sso_config"]
449
+ _get_sso_discovery = _sso_runtime["_get_sso_discovery"]
450
+ _sso_states = _sso_runtime["_sso_states"]
507
451
 
508
452
  # MCP/skill request models moved to latticeai.api.mcp (v1.3.0).
509
- DEFAULT_VPC_CONFIG = {
510
- "provider": "AWS",
511
- "region": "ap-northeast-2",
512
- "cidr_block": "10.42.0.0/16",
513
- "private_subnets": ["10.42.10.0/24", "10.42.20.0/24"],
514
- "endpoint": "ltcai-private.local",
515
- "vpn_status": "standby",
516
- "peering_status": "not_configured",
517
- "notes": "로컬 MLX 브릿지를 프라이빗 서브넷 또는 VPN 뒤에서 운영할 때 쓰는 네트워크 프로필입니다.",
518
- "updated_at": None,
519
- }
453
+ # VPC network-profile config → latticeai.runtime.network_config_runtime.
454
+ _vpc_runtime = build_vpc_runtime(vpc_file=VPC_FILE, logging=logging)
455
+ DEFAULT_VPC_CONFIG = _vpc_runtime["DEFAULT_VPC_CONFIG"]
456
+ load_vpc_config = _vpc_runtime["load_vpc_config"]
457
+ save_vpc_config = _vpc_runtime["save_vpc_config"]
520
458
 
521
459
 
522
460
  def load_users():
@@ -539,22 +477,6 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
539
477
  def save_users(users):
540
478
  save_users_file(USERS_FILE, users)
541
479
 
542
- def load_vpc_config() -> Dict:
543
- if not os.path.exists(VPC_FILE):
544
- return DEFAULT_VPC_CONFIG.copy()
545
- try:
546
- with open(VPC_FILE, "r", encoding="utf-8") as f:
547
- stored = json.load(f)
548
- return {**DEFAULT_VPC_CONFIG, **stored}
549
- except Exception as e:
550
- logging.warning("load_vpc_config failed (using defaults): %s", e)
551
- return DEFAULT_VPC_CONFIG.copy()
552
-
553
- def save_vpc_config(config: Dict):
554
- config["updated_at"] = datetime.now().isoformat()
555
- with open(VPC_FILE, "w", encoding="utf-8") as f:
556
- json.dump(config, f, ensure_ascii=False, indent=2)
557
-
558
480
 
559
481
  _history_lock = threading.Lock()
560
482
 
@@ -639,131 +561,28 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
639
561
  get_audit_log = _audit_rt["get_audit_log"]
640
562
  append_audit_event = _audit_rt["append_audit_event"]
641
563
 
642
- def _history_allowed_workspaces_for(user_email: Optional[str]):
643
- if not REQUIRE_AUTH or not user_email:
644
- return None
645
- try:
646
- return set(WORKSPACE_SERVICE.readable_workspaces(user_email))
647
- except Exception as exc:
648
- logging.warning("history workspace scope resolution failed for %s: %s", user_email, exc)
649
- return set()
650
-
651
- def _history_include_legacy_global(user_email: Optional[str]) -> bool:
652
- return not REQUIRE_AUTH or not user_email
653
-
654
- def get_history(
655
- user_email: Optional[str] = None,
656
- allowed_workspaces=None,
657
- include_legacy_global: Optional[bool] = None,
658
- ):
659
- try:
660
- if allowed_workspaces is None and user_email:
661
- allowed_workspaces = _history_allowed_workspaces_for(user_email)
662
- if include_legacy_global is None:
663
- include_legacy_global = _history_include_legacy_global(user_email)
664
- return CONVERSATIONS.history(
665
- user_email=user_email,
666
- allowed_workspaces=allowed_workspaces,
667
- include_legacy_global=include_legacy_global,
668
- )
669
- except Exception as e:
670
- logging.warning("get_history failed: %s", e)
671
- return []
564
+ # History query/clear seam (scope resolution, get_history, grouping, clears)
565
+ # latticeai.runtime.history_runtime. save_to_history (the write path) stays
566
+ # inline above because it is bound to redaction/audit/ingestion.
567
+ _history_query_runtime = build_history_query_runtime(
568
+ conversations=CONVERSATIONS,
569
+ workspace_service=WORKSPACE_SERVICE,
570
+ require_auth=REQUIRE_AUTH,
571
+ logging=logging,
572
+ )
573
+ _history_allowed_workspaces_for = _history_query_runtime["_history_allowed_workspaces_for"]
574
+ _history_include_legacy_global = _history_query_runtime["_history_include_legacy_global"]
575
+ get_history = _history_query_runtime["get_history"]
576
+ conversation_title = _history_query_runtime["conversation_title"]
577
+ group_history_conversations = _history_query_runtime["group_history_conversations"]
578
+ get_conversation_messages = _history_query_runtime["get_conversation_messages"]
579
+ clear_history = _history_query_runtime["clear_history"]
580
+ clear_conversation = _history_query_runtime["clear_conversation"]
672
581
 
673
582
  # Chat service seam: behaviour-preserving façade for history access and
674
583
  # Workspace-OS answer-trace recording used by the (unchanged) streaming chat path.
675
584
  CHAT_SERVICE = ChatService(store=WORKSPACE_OS, get_history=get_history)
676
585
 
677
- def conversation_title(item: Dict) -> str:
678
- content = str(item.get("content") or "").strip()
679
- content = re.sub(r"\s+", " ", content)
680
- return content[:48] or "새 대화"
681
-
682
- def group_history_conversations(history: Optional[List[Dict]] = None) -> List[Dict]:
683
- history = history if history is not None else get_history()
684
- conversations: Dict[str, Dict] = {}
685
- order: List[str] = []
686
-
687
- for index, item in enumerate(history):
688
- conv_id = item.get("conversation_id")
689
- if not conv_id:
690
- conv_id = "legacy-previous-history"
691
-
692
- if conv_id not in conversations:
693
- conversations[conv_id] = {
694
- "id": conv_id,
695
- "title": "이전 대화 기록" if conv_id == "legacy-previous-history" else conversation_title(item),
696
- "created_at": item.get("timestamp"),
697
- "updated_at": item.get("timestamp"),
698
- "message_count": 0,
699
- "last_message": "",
700
- "source": item.get("source"),
701
- }
702
- order.append(conv_id)
703
-
704
- conv = conversations[conv_id]
705
- conv["message_count"] += 1
706
- conv["updated_at"] = item.get("timestamp") or conv.get("updated_at")
707
- conv["last_message"] = conversation_title(item)
708
- if conv_id != "legacy-previous-history" and item.get("role") == "user" and (not conv.get("title") or conv["title"] == "새 대화"):
709
- conv["title"] = conversation_title(item)
710
-
711
- return sorted((conversations[key] for key in order), key=lambda item: item.get("updated_at") or "", reverse=True)
712
-
713
- def get_conversation_messages(
714
- conversation_id: str,
715
- *,
716
- user_email: Optional[str] = None,
717
- allowed_workspaces=None,
718
- include_legacy_global: Optional[bool] = None,
719
- ) -> List[Dict]:
720
- history = get_history(
721
- user_email=user_email,
722
- allowed_workspaces=allowed_workspaces,
723
- include_legacy_global=include_legacy_global,
724
- )
725
- if conversation_id == "legacy-previous-history":
726
- return [item for item in history if not item.get("conversation_id")]
727
- return [item for item in history if item.get("conversation_id") == conversation_id]
728
-
729
- def clear_history(
730
- keep_last: int = 0,
731
- *,
732
- user_email: Optional[str] = None,
733
- allowed_workspaces=None,
734
- include_legacy_global: Optional[bool] = None,
735
- ) -> Dict:
736
- if allowed_workspaces is None and user_email:
737
- allowed_workspaces = _history_allowed_workspaces_for(user_email)
738
- if include_legacy_global is None:
739
- include_legacy_global = _history_include_legacy_global(user_email)
740
- return CONVERSATIONS.clear_all(
741
- keep_last=keep_last,
742
- user_email=user_email,
743
- allowed_workspaces=allowed_workspaces,
744
- include_legacy_global=include_legacy_global,
745
- )
746
-
747
- def clear_conversation(
748
- conversation_id: str,
749
- started_at: Optional[str] = None,
750
- *,
751
- user_email: Optional[str] = None,
752
- allowed_workspaces=None,
753
- include_legacy_global: Optional[bool] = None,
754
- ) -> Dict:
755
- if allowed_workspaces is None and user_email:
756
- allowed_workspaces = _history_allowed_workspaces_for(user_email)
757
- if include_legacy_global is None:
758
- include_legacy_global = _history_include_legacy_global(user_email)
759
- return CONVERSATIONS.clear_conversation(
760
- conversation_id,
761
- started_at=started_at,
762
- user_email=user_email,
763
- allowed_workspaces=allowed_workspaces,
764
- include_legacy_global=include_legacy_global,
765
- )
766
-
767
586
  _access_runtime = build_access_runtime(
768
587
  config=CONFIG,
769
588
  require_auth=REQUIRE_AUTH,
@@ -790,136 +609,24 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
790
609
  def _bytes_match_extension(data: bytes, ext: str) -> bool:
791
610
  return _bytes_match_extension_impl(data, ext)
792
611
 
793
- _LOCAL_APPROVAL_TTL_SECONDS = 5 * 60
794
- _local_approvals: Dict[str, Dict[str, object]] = {}
795
-
796
-
797
- def _normalize_local_path_for_approval(path: str) -> str:
798
- return str(Path(path).expanduser().resolve())
799
-
800
-
801
- def _content_fingerprint(content: str = "") -> str:
802
- return hashlib.sha256(content.encode("utf-8")).hexdigest()
803
-
804
-
805
- def _local_permission_response(path: str, action: str, user_email: str, content: str = "") -> dict:
806
- normalized = _normalize_local_path_for_approval(path)
807
- token = secrets.token_urlsafe(24)
808
- record: Dict[str, object] = {
809
- "path": normalized,
810
- "action": action,
811
- "user_email": user_email,
812
- "expires_at": time.time() + _LOCAL_APPROVAL_TTL_SECONDS,
813
- "approved": False,
814
- }
815
- if action == "write":
816
- record["content_hash"] = _content_fingerprint(content)
817
- _local_approvals[token] = record
818
- return {
819
- "permission_required": True,
820
- "path": path,
821
- "action": action,
822
- "approval_token": token,
823
- "expires_in": _LOCAL_APPROVAL_TTL_SECONDS,
824
- }
825
-
612
+ # Local file-access approval lives entirely in
613
+ # ``latticeai.api.permissions.PermissionGateway`` (token hash-at-rest,
614
+ # persisted approval queue, Discord notifications). The historical inline
615
+ # copy that used to sit here was app-dead — no route referenced it — so it
616
+ # was removed to keep this factory a single wiring path.
826
617
 
827
- def _require_local_approval(
828
- *,
829
- token: Optional[str],
830
- path: str,
831
- action: str,
832
- user_email: str,
833
- content: str = "",
834
- ) -> None:
835
- if not token:
836
- raise HTTPException(status_code=403, detail="파일 접근 승인 토큰이 필요합니다.")
837
- record = _local_approvals.get(token)
838
- if not record or float(record.get("expires_at", 0)) < time.time():
839
- raise HTTPException(status_code=403, detail="파일 접근 승인이 만료되었거나 유효하지 않습니다.")
840
- if not record.get("approved"):
841
- raise HTTPException(status_code=403, detail="파일 접근이 아직 승인되지 않았습니다.")
842
- if record.get("user_email") != user_email:
843
- raise HTTPException(status_code=403, detail="다른 사용자의 파일 접근 승인은 사용할 수 없습니다.")
844
- if record.get("path") != _normalize_local_path_for_approval(path) or record.get("action") != action:
845
- raise HTTPException(status_code=403, detail="파일 접근 승인 범위가 일치하지 않습니다.")
846
- if action == "write" and record.get("content_hash") != _content_fingerprint(content):
847
- raise HTTPException(status_code=403, detail="승인된 파일 내용과 요청 내용이 다릅니다.")
848
-
849
-
850
- def get_history_user(email: Optional[str], nickname: Optional[str] = None) -> Dict:
851
- if not email:
852
- return {"user_email": None, "user_nickname": nickname or None}
853
- users = load_users()
854
- user = users.get(email, {})
855
- return {
856
- "user_email": email,
857
- "user_nickname": nickname or user.get("nickname") or user.get("name") or email,
858
- }
859
-
860
- def get_user_api_key(email: Optional[str], provider: str) -> Optional[str]:
861
- if not email:
862
- return None
863
- keyring_key = f"{email}:{provider}"
864
- if keyring is not None:
865
- try:
866
- key = keyring.get_password("LatticeAI", keyring_key)
867
- if key:
868
- return key.strip()
869
- except Exception as exc:
870
- logging.warning("keyring read failed for %s: %s", provider, exc)
871
- users = load_users()
872
- user = users.get(email) or {}
873
- api_keys = user.get("api_keys") or {}
874
- key = api_keys.get(provider)
875
- if isinstance(key, str) and key.strip() and ALLOW_PLAINTEXT_API_KEYS:
876
- return key.strip()
877
- return None
878
-
879
- def set_user_api_key(email: str, provider: str, key: str) -> None:
880
- keyring_key = f"{email}:{provider}"
881
- if keyring is not None:
882
- try:
883
- keyring.set_password("LatticeAI", keyring_key, key)
884
- users = load_users()
885
- user = users.get(email)
886
- if user and "api_keys" in user:
887
- user["api_keys"].pop(provider, None)
888
- if not user["api_keys"]:
889
- user.pop("api_keys", None)
890
- save_users(users)
891
- return
892
- except Exception as exc:
893
- logging.warning("keyring write failed for %s: %s", provider, exc)
894
- if not ALLOW_PLAINTEXT_API_KEYS:
895
- raise HTTPException(
896
- status_code=500,
897
- detail="OS keyring에 API 키를 저장하지 못했습니다. keyring 설정을 확인하거나 LATTICEAI_ALLOW_PLAINTEXT_API_KEYS=true를 명시적으로 설정하세요.",
898
- )
899
-
900
- if not ALLOW_PLAINTEXT_API_KEYS:
901
- raise HTTPException(
902
- status_code=500,
903
- detail="keyring 패키지를 사용할 수 없어 API 키를 안전하게 저장할 수 없습니다.",
904
- )
905
-
906
- users = load_users()
907
- user = users.get(email)
908
- if not user:
909
- user = {
910
- "password_hash": "",
911
- "salt": "",
912
- "name": email,
913
- "nickname": email,
914
- "role": "user",
915
- "disabled": False,
916
- }
917
- ensure_user_identity(email, user)
918
- api_keys = user.get("api_keys") or {}
919
- api_keys[provider] = key
920
- user["api_keys"] = api_keys
921
- users[email] = user
922
- save_users(users)
618
+ _user_key_runtime = build_user_key_runtime(
619
+ load_users=load_users,
620
+ save_users=save_users,
621
+ ensure_user_identity=ensure_user_identity,
622
+ keyring=keyring,
623
+ allow_plaintext_api_keys=ALLOW_PLAINTEXT_API_KEYS,
624
+ logging=logging,
625
+ http_exception=HTTPException,
626
+ )
627
+ get_history_user = _user_key_runtime["get_history_user"]
628
+ get_user_api_key = _user_key_runtime["get_user_api_key"]
629
+ set_user_api_key = _user_key_runtime["set_user_api_key"]
923
630
 
924
631
  # ── Sensitivity analysis — delegated to latticeai.core.audit ──────────────────
925
632
  def classify_sensitive_message(item: Dict, index: int) -> Dict:
@@ -1450,9 +1157,24 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1450
1157
  uvicorn.run(app, host=DEFAULT_HOST, port=DEFAULT_PORT, log_level="info")
1451
1158
 
1452
1159
  # ── Constructed-namespace export (consumed by AppRuntime) ────────────────
1453
- # Every local singletons, helper functions, request models becomes an
1454
- # attribute of the runtime so the legacy ``server_app`` surface survives.
1455
- return dict(locals())
1160
+ # Public runtime objects and selected legacy helpers remain available via
1161
+ # ``server_app.__getattr__``. Internal assembly scratch values are filtered
1162
+ # out so the compatibility namespace can keep shrinking toward DI.
1163
+ runtime_bundle = RuntimeBundle(
1164
+ app=app,
1165
+ CONFIG=CONFIG,
1166
+ KNOWLEDGE_GRAPH=KNOWLEDGE_GRAPH,
1167
+ INGESTION_PIPELINE=INGESTION_PIPELINE,
1168
+ AGENT_RUNTIME=AGENT_RUNTIME,
1169
+ HOOKS_REGISTRY=HOOKS_REGISTRY,
1170
+ REVIEW_QUEUE=REVIEW_QUEUE,
1171
+ AGENT_REGISTRY=AGENT_REGISTRY,
1172
+ model_router=router,
1173
+ build_runtime=build_runtime,
1174
+ get_shared_runtime=get_shared_runtime,
1175
+ create_app=create_app,
1176
+ )
1177
+ return build_runtime_namespace(locals(), runtime_bundle=runtime_bundle)
1456
1178
 
1457
1179
 
1458
1180
  @dataclass(frozen=True)
@@ -0,0 +1,37 @@
1
+ """Shared small IO helpers for JSON, timestamps, and file hashes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional
11
+
12
+
13
+ def atomic_write_json(path: Path, payload: Dict[str, Any]) -> None:
14
+ path.parent.mkdir(parents=True, exist_ok=True)
15
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
16
+ tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
17
+ os.replace(tmp_path, path)
18
+
19
+
20
+ def parse_iso(value: Optional[str]) -> Optional[datetime]:
21
+ if not value:
22
+ return None
23
+ try:
24
+ return datetime.fromisoformat(str(value))
25
+ except (TypeError, ValueError):
26
+ return None
27
+
28
+
29
+ def sha256_file(path: Path) -> str:
30
+ digest = hashlib.sha256()
31
+ with open(path, "rb") as fh:
32
+ for block in iter(lambda: fh.read(65536), b""):
33
+ digest.update(block)
34
+ return digest.hexdigest()
35
+
36
+
37
+ __all__ = ["atomic_write_json", "parse_iso", "sha256_file"]
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
16
 
17
- LEGACY_COMPATIBILITY_VERSION = "8.9.0"
17
+ LEGACY_COMPATIBILITY_VERSION = "9.0.0"
18
18
 
19
19
 
20
20
  @dataclass(frozen=True)
@@ -11,15 +11,13 @@ from __future__ import annotations
11
11
 
12
12
  import hashlib
13
13
  import math
14
+ import os
14
15
  import re
15
16
  import struct
16
17
  from dataclasses import dataclass
17
18
  from typing import Iterable, List
18
19
 
19
- # Default controlled via latticeai.core.config.Config.embedding_dim (LATTICEAI_VECTOR_DIM).
20
- # Removed direct os.getenv per 7.6.0 config centralization (review.md item 3).
21
- # Callers that need override should pass dim= from Config.
22
- DEFAULT_EMBEDDING_DIM = 384
20
+ DEFAULT_EMBEDDING_DIM = int(os.getenv("LATTICEAI_VECTOR_DIM", "384"))
23
21
  EMBEDDING_MODEL_ID = f"lattice-local-hash-v1:{DEFAULT_EMBEDDING_DIM}"
24
22
 
25
23
 
@@ -11,8 +11,8 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "8.9.0"
15
- TEMPLATE_KINDS = ("plugin", "workflow", "agent")
14
+ MARKETPLACE_VERSION = "9.0.0"
15
+ TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
16
16
 
17
17
 
18
18
  def _agent_template(
@@ -144,6 +144,37 @@ BUILTIN_TEMPLATES: Dict[str, List[Dict[str, Any]]] = {
144
144
  category="automation",
145
145
  ),
146
146
  ],
147
+ "ingestion_bridge": [
148
+ {
149
+ "id": "bridge-obsidian-markdown",
150
+ "kind": "ingestion_bridge",
151
+ "name": "Obsidian Markdown Bridge",
152
+ "version": "1.0.0",
153
+ "description": "Import a local Markdown vault through the unified ingestion pipeline.",
154
+ "metadata": {"category": "interop", "installable": True},
155
+ "definition": {
156
+ "source_types": ["local_file", "markdown"],
157
+ "file_patterns": ["*.md"],
158
+ "pipeline": "unified-ingestion",
159
+ "provenance": True,
160
+ "graph_edges": ["indexed_from", "mentions"],
161
+ },
162
+ },
163
+ {
164
+ "id": "bridge-calendar-notes",
165
+ "kind": "ingestion_bridge",
166
+ "name": "Calendar Notes Bridge",
167
+ "version": "1.0.0",
168
+ "description": "Normalize calendar meeting notes into Brain events and source-linked notes.",
169
+ "metadata": {"category": "interop", "installable": True},
170
+ "definition": {
171
+ "source_types": ["workspace_event", "note"],
172
+ "pipeline": "unified-ingestion",
173
+ "provenance": True,
174
+ "graph_edges": ["indexed_from", "mentions"],
175
+ },
176
+ },
177
+ ],
147
178
  }
148
179
 
149
180