ltcai 8.8.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 (111) hide show
  1. package/README.md +33 -37
  2. package/auto_setup.py +84 -70
  3. package/docs/CHANGELOG.md +113 -237
  4. package/docs/CODE_REVIEW_2026-07-06.md +764 -0
  5. package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
  6. package/docs/DEVELOPMENT.md +10 -10
  7. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  8. package/docs/ONBOARDING.md +2 -2
  9. package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
  10. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  11. package/docs/TRUST_MODEL.md +5 -1
  12. package/docs/WHY_LATTICE.md +4 -3
  13. package/docs/architecture.md +4 -0
  14. package/docs/kg-schema.md +1 -1
  15. package/lattice_brain/__init__.py +1 -1
  16. package/lattice_brain/archive.py +4 -9
  17. package/lattice_brain/conversations.py +156 -21
  18. package/lattice_brain/embeddings.py +38 -2
  19. package/lattice_brain/graph/_kg_common.py +39 -496
  20. package/lattice_brain/graph/_kg_constants.py +243 -0
  21. package/lattice_brain/graph/_kg_fsutil.py +297 -0
  22. package/lattice_brain/graph/discovery.py +0 -948
  23. package/lattice_brain/graph/discovery_index.py +972 -0
  24. package/lattice_brain/graph/json_utils.py +25 -0
  25. package/lattice_brain/graph/retrieval.py +66 -597
  26. package/lattice_brain/graph/retrieval_docgen.py +210 -0
  27. package/lattice_brain/graph/retrieval_vector.py +460 -0
  28. package/lattice_brain/graph/runtime.py +16 -0
  29. package/lattice_brain/graph/store.py +6 -0
  30. package/lattice_brain/ingestion.py +68 -0
  31. package/lattice_brain/portability.py +1 -9
  32. package/lattice_brain/quality.py +98 -4
  33. package/lattice_brain/runtime/agent_runtime.py +166 -0
  34. package/lattice_brain/runtime/multi_agent.py +1 -1
  35. package/lattice_brain/utils.py +28 -0
  36. package/latticeai/__init__.py +1 -1
  37. package/latticeai/api/chat.py +368 -418
  38. package/latticeai/api/chat_helpers.py +227 -0
  39. package/latticeai/api/computer_use.py +149 -31
  40. package/latticeai/api/marketplace.py +11 -0
  41. package/latticeai/api/mcp.py +3 -2
  42. package/latticeai/api/models.py +4 -1
  43. package/latticeai/api/permissions.py +72 -33
  44. package/latticeai/api/setup.py +17 -2
  45. package/latticeai/api/tools.py +105 -62
  46. package/latticeai/app_factory.py +101 -296
  47. package/latticeai/core/agent.py +25 -7
  48. package/latticeai/core/io_utils.py +37 -0
  49. package/latticeai/core/legacy_compatibility.py +1 -1
  50. package/latticeai/core/local_embeddings.py +2 -4
  51. package/latticeai/core/marketplace.py +33 -2
  52. package/latticeai/core/mcp_catalog.py +450 -0
  53. package/latticeai/core/mcp_registry.py +2 -441
  54. package/latticeai/core/sessions.py +11 -3
  55. package/latticeai/core/tool_registry.py +15 -4
  56. package/latticeai/core/users.py +4 -9
  57. package/latticeai/core/workspace_os.py +1 -1
  58. package/latticeai/core/workspace_os_utils.py +3 -17
  59. package/latticeai/integrations/telegram_bot.py +7 -2
  60. package/latticeai/models/model_providers.py +111 -0
  61. package/latticeai/models/router.py +58 -136
  62. package/latticeai/runtime/audit_runtime.py +27 -16
  63. package/latticeai/runtime/automation_runtime.py +9 -0
  64. package/latticeai/runtime/bootstrap.py +1 -1
  65. package/latticeai/runtime/history_runtime.py +163 -0
  66. package/latticeai/runtime/namespace_runtime.py +173 -0
  67. package/latticeai/runtime/network_config_runtime.py +56 -0
  68. package/latticeai/runtime/sso_config_runtime.py +128 -0
  69. package/latticeai/runtime/user_key_runtime.py +106 -0
  70. package/latticeai/services/app_context.py +1 -0
  71. package/latticeai/services/architecture_readiness.py +2 -2
  72. package/latticeai/services/memory_service.py +213 -0
  73. package/latticeai/services/model_engines.py +79 -12
  74. package/latticeai/services/model_runtime.py +24 -4
  75. package/latticeai/services/platform_runtime.py +9 -1
  76. package/latticeai/services/process_audit.py +208 -0
  77. package/latticeai/services/product_readiness.py +11 -11
  78. package/latticeai/services/review_queue.py +64 -11
  79. package/latticeai/services/run_executor.py +21 -0
  80. package/latticeai/services/search_service.py +106 -30
  81. package/latticeai/services/setup_detection.py +80 -0
  82. package/latticeai/services/tool_dispatch.py +66 -0
  83. package/latticeai/services/workspace_service.py +15 -0
  84. package/package.json +1 -1
  85. package/scripts/check_i18n_literals.mjs +20 -8
  86. package/scripts/i18n_literal_allowlist.json +34 -0
  87. package/scripts/lint_frontend.mjs +6 -2
  88. package/setup_wizard.py +196 -74
  89. package/src-tauri/Cargo.lock +1 -1
  90. package/src-tauri/Cargo.toml +1 -1
  91. package/src-tauri/tauri.conf.json +1 -1
  92. package/static/app/asset-manifest.json +11 -11
  93. package/static/app/assets/{Act-C7K9wsO9.js → Act-21lIXx2E.js} +1 -1
  94. package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
  95. package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-BA7Z2Q1u.js} +1 -1
  96. package/static/app/assets/{Library-Cgj-EF50.js → Library-bFMtyni3.js} +1 -1
  97. package/static/app/assets/System-K6krGCqn.js +1 -0
  98. package/static/app/assets/index-C4R3ws30.js +17 -0
  99. package/static/app/assets/index-ChSeOB02.css +2 -0
  100. package/static/app/assets/primitives-sQU3it5I.js +1 -0
  101. package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-DK3Fd_lR.js} +1 -1
  102. package/static/app/index.html +2 -2
  103. package/static/css/tokens.css +4 -2
  104. package/static/sw.js +1 -1
  105. package/tools/local_files.py +6 -0
  106. package/latticeai/runtime/sso_runtime.py +0 -52
  107. package/static/app/assets/Brain-I1OSzxJu.js +0 -321
  108. package/static/app/assets/System-D1Lkei3I.js +0 -1
  109. package/static/app/assets/index--P0ksosz.js +0 -17
  110. package/static/app/assets/index-DCh5AoXt.css +0 -2
  111. package/static/app/assets/primitives-BLqaKk5g.js +0 -1
@@ -15,6 +15,7 @@ lazily via module ``__getattr__`` for backwards compatibility.
15
15
  from __future__ import annotations
16
16
 
17
17
  import threading
18
+ from dataclasses import dataclass
18
19
  from typing import TYPE_CHECKING, Any, Dict, List, Optional
19
20
 
20
21
  from latticeai.runtime.app_context_runtime import build_app_context
@@ -33,18 +34,21 @@ from latticeai.runtime.hooks_runtime import (
33
34
  bind_trigger_hook_runner,
34
35
  build_hooks_runtime,
35
36
  )
37
+ from latticeai.runtime.history_runtime import build_history_query_runtime
36
38
  from latticeai.runtime.lifespan_runtime import build_lifespan_runtime
39
+ from latticeai.runtime.network_config_runtime import build_vpc_runtime
37
40
  from latticeai.runtime.model_wiring import (
38
41
  configure_model_runtime_from_context,
39
42
  register_model_runtime_routers,
40
43
  )
44
+ from latticeai.runtime.namespace_runtime import RuntimeBundle, build_runtime_namespace
41
45
  from latticeai.runtime.platform_services_runtime import (
42
46
  build_brain_network,
43
47
  )
44
48
  from latticeai.runtime.platform_runtime_wiring import build_platform_automation_runtime
45
49
  from latticeai.runtime.persistence_runtime import build_persistence_runtime
46
50
  from latticeai.runtime.review_wiring import build_review_run_now_runner
47
- from latticeai.runtime.sso_runtime import build_sso_runtime
51
+ from latticeai.runtime.sso_config_runtime import build_sso_config_runtime
48
52
  from latticeai.runtime.audit_runtime import build_audit_runtime
49
53
  from latticeai.runtime.router_registration import (
50
54
  build_auth_admin_security_router_bundle,
@@ -57,6 +61,7 @@ from latticeai.runtime.router_registration import (
57
61
  )
58
62
  from latticeai.runtime.security_runtime import build_security_runtime
59
63
  from latticeai.runtime.tail_wiring import register_tail_runtime_routers
64
+ from latticeai.runtime.user_key_runtime import build_user_key_runtime
60
65
  from latticeai.runtime.web_runtime import build_web_runtime
61
66
 
62
67
  if TYPE_CHECKING: # imports for annotations only — keep module import light
@@ -72,14 +77,9 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
72
77
  deliberately *inside* this function so that importing the module performs
73
78
  no GPU init, no singleton construction, and no filesystem writes.
74
79
  """
75
- import hashlib
76
- import json
77
80
  import logging
78
81
  import os
79
- import re
80
- import secrets
81
82
  import threading
82
- import time
83
83
  from pathlib import Path
84
84
 
85
85
  try:
@@ -94,7 +94,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
94
94
  from pydantic import BaseModel
95
95
 
96
96
  from latticeai.models.router import LLMRouter, normalize_branding
97
- from lattice_brain.graph._kg_common import set_llm_router
97
+ from lattice_brain.graph.runtime import set_llm_router
98
98
  from lattice_brain.graph.schema import set_embed_dim
99
99
  from latticeai.core.security import (
100
100
  hash_password,
@@ -234,6 +234,8 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
234
234
  ENABLE_GRAPH = _config_runtime["ENABLE_GRAPH"]
235
235
  AUTOLOAD_MODELS = _config_runtime["AUTOLOAD_MODELS"]
236
236
  MODEL_IDLE_UNLOAD_SECONDS = _config_runtime["MODEL_IDLE_UNLOAD_SECONDS"]
237
+ ALLOW_MODEL_DOWNLOADS = _config_runtime["ALLOW_MODEL_DOWNLOADS"]
238
+ MODEL_DOWNLOAD_TIMEOUT = _config_runtime["MODEL_DOWNLOAD_TIMEOUT"]
237
239
  ALLOW_LOCAL_MODELS = _config_runtime["ALLOW_LOCAL_MODELS"]
238
240
  REQUIRE_AUTH = _config_runtime["REQUIRE_AUTH"]
239
241
  ALLOW_PLAINTEXT_API_KEYS = _config_runtime["ALLOW_PLAINTEXT_API_KEYS"]
@@ -252,11 +254,8 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
252
254
  SSO_REDIRECT_URI = _security_runtime["SSO_REDIRECT_URI"]
253
255
  SSO_PROVIDER_NAME = _security_runtime["SSO_PROVIDER_NAME"]
254
256
 
255
- # SSO cache + discovery built after get_sso_settings is defined (see below)
256
- _sso_discovery_cache = None
257
- _sso_discovery_cache_url = ""
258
- _sso_states: Dict[str, float] = {}
259
- _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).
260
259
 
261
260
  # ── Password hashing — used directly from latticeai.core.security ──────────────
262
261
  # (hash_password / verify_password are imported above; no local wrapper needed)
@@ -431,89 +430,31 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
431
430
  redirect_uri: Optional[str] = None
432
431
  scopes: Optional[str] = None
433
432
 
434
- def _sso_env_defaults() -> Dict[str, object]:
435
- return {
436
- "enabled": bool(SSO_DISCOVERY_URL and SSO_CLIENT_ID and SSO_CLIENT_SECRET),
437
- "provider_name": SSO_PROVIDER_NAME,
438
- "discovery_url": SSO_DISCOVERY_URL,
439
- "client_id": SSO_CLIENT_ID,
440
- "client_secret": SSO_CLIENT_SECRET,
441
- "redirect_uri": SSO_REDIRECT_URI,
442
- "scopes": "openid email profile",
443
- }
444
-
445
- def load_sso_config() -> Dict[str, object]:
446
- config = _sso_env_defaults()
447
- if SSO_FILE.exists():
448
- try:
449
- data = json.loads(SSO_FILE.read_text(encoding="utf-8"))
450
- if isinstance(data, dict):
451
- config.update({k: v for k, v in data.items() if v is not None})
452
- except Exception as e:
453
- logging.warning("load_sso_config failed (using env/defaults): %s", e)
454
- config["provider_name"] = str(config.get("provider_name") or "SSO")
455
- config["discovery_url"] = str(config.get("discovery_url") or "")
456
- config["client_id"] = str(config.get("client_id") or "")
457
- config["client_secret"] = str(config.get("client_secret") or "")
458
- config["redirect_uri"] = str(config.get("redirect_uri") or SSO_REDIRECT_URI)
459
- config["scopes"] = str(config.get("scopes") or "openid email profile")
460
- config["enabled"] = bool(config.get("enabled")) and bool(
461
- config["discovery_url"] and config["client_id"] and config["client_secret"]
462
- )
463
- return config
464
-
465
- def get_sso_settings() -> Dict[str, object]:
466
- return load_sso_config()
467
-
468
- # Build SSO runtime (cache + helper) now that get_sso_settings exists in scope.
469
- if _get_sso_discovery is None:
470
- _sso = build_sso_runtime(
471
- get_sso_settings=get_sso_settings,
472
- logging=logging,
473
- )
474
- _sso_discovery_cache = _sso["_sso_discovery_cache"]
475
- _sso_discovery_cache_url = _sso["_sso_discovery_cache_url"]
476
- _sso_states = _sso["_sso_states"]
477
- _get_sso_discovery = _sso["_get_sso_discovery"]
478
-
479
- def public_sso_config(config: Optional[Dict[str, object]] = None) -> Dict[str, object]:
480
- cfg = config or get_sso_settings()
481
- return {
482
- "enabled": bool(cfg.get("enabled")),
483
- "provider_name": cfg.get("provider_name") or "",
484
- "discovery_url": cfg.get("discovery_url") or "",
485
- "client_id": cfg.get("client_id") or "",
486
- "redirect_uri": cfg.get("redirect_uri") or SSO_REDIRECT_URI,
487
- "scopes": cfg.get("scopes") or "openid email profile",
488
- "secret_configured": bool(cfg.get("client_secret")),
489
- }
490
-
491
- def save_sso_config(update: Dict[str, object]) -> Dict[str, object]:
492
- nonlocal _sso_discovery_cache, _sso_discovery_cache_url
493
- current = load_sso_config()
494
- if update.get("client_secret") == "":
495
- update.pop("client_secret", None)
496
- current.update({k: v for k, v in update.items() if v is not None})
497
- current["enabled"] = bool(current.get("enabled")) and bool(
498
- current.get("discovery_url") and current.get("client_id") and current.get("client_secret")
499
- )
500
- SSO_FILE.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8")
501
- _sso_discovery_cache = None
502
- _sso_discovery_cache_url = ""
503
- 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"]
504
451
 
505
452
  # MCP/skill request models moved to latticeai.api.mcp (v1.3.0).
506
- DEFAULT_VPC_CONFIG = {
507
- "provider": "AWS",
508
- "region": "ap-northeast-2",
509
- "cidr_block": "10.42.0.0/16",
510
- "private_subnets": ["10.42.10.0/24", "10.42.20.0/24"],
511
- "endpoint": "ltcai-private.local",
512
- "vpn_status": "standby",
513
- "peering_status": "not_configured",
514
- "notes": "로컬 MLX 브릿지를 프라이빗 서브넷 또는 VPN 뒤에서 운영할 때 쓰는 네트워크 프로필입니다.",
515
- "updated_at": None,
516
- }
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"]
517
458
 
518
459
 
519
460
  def load_users():
@@ -536,22 +477,6 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
536
477
  def save_users(users):
537
478
  save_users_file(USERS_FILE, users)
538
479
 
539
- def load_vpc_config() -> Dict:
540
- if not os.path.exists(VPC_FILE):
541
- return DEFAULT_VPC_CONFIG.copy()
542
- try:
543
- with open(VPC_FILE, "r", encoding="utf-8") as f:
544
- stored = json.load(f)
545
- return {**DEFAULT_VPC_CONFIG, **stored}
546
- except Exception as e:
547
- logging.warning("load_vpc_config failed (using defaults): %s", e)
548
- return DEFAULT_VPC_CONFIG.copy()
549
-
550
- def save_vpc_config(config: Dict):
551
- config["updated_at"] = datetime.now().isoformat()
552
- with open(VPC_FILE, "w", encoding="utf-8") as f:
553
- json.dump(config, f, ensure_ascii=False, indent=2)
554
-
555
480
 
556
481
  _history_lock = threading.Lock()
557
482
 
@@ -636,65 +561,28 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
636
561
  get_audit_log = _audit_rt["get_audit_log"]
637
562
  append_audit_event = _audit_rt["append_audit_event"]
638
563
 
639
- def get_history():
640
- try:
641
- return CONVERSATIONS.history()
642
- except Exception as e:
643
- logging.warning("get_history failed: %s", e)
644
- 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"]
645
581
 
646
582
  # Chat service seam: behaviour-preserving façade for history access and
647
583
  # Workspace-OS answer-trace recording used by the (unchanged) streaming chat path.
648
584
  CHAT_SERVICE = ChatService(store=WORKSPACE_OS, get_history=get_history)
649
585
 
650
- def conversation_title(item: Dict) -> str:
651
- content = str(item.get("content") or "").strip()
652
- content = re.sub(r"\s+", " ", content)
653
- return content[:48] or "새 대화"
654
-
655
- def group_history_conversations(history: Optional[List[Dict]] = None) -> List[Dict]:
656
- history = history if history is not None else get_history()
657
- conversations: Dict[str, Dict] = {}
658
- order: List[str] = []
659
-
660
- for index, item in enumerate(history):
661
- conv_id = item.get("conversation_id")
662
- if not conv_id:
663
- conv_id = "legacy-previous-history"
664
-
665
- if conv_id not in conversations:
666
- conversations[conv_id] = {
667
- "id": conv_id,
668
- "title": "이전 대화 기록" if conv_id == "legacy-previous-history" else conversation_title(item),
669
- "created_at": item.get("timestamp"),
670
- "updated_at": item.get("timestamp"),
671
- "message_count": 0,
672
- "last_message": "",
673
- "source": item.get("source"),
674
- }
675
- order.append(conv_id)
676
-
677
- conv = conversations[conv_id]
678
- conv["message_count"] += 1
679
- conv["updated_at"] = item.get("timestamp") or conv.get("updated_at")
680
- conv["last_message"] = conversation_title(item)
681
- if conv_id != "legacy-previous-history" and item.get("role") == "user" and (not conv.get("title") or conv["title"] == "새 대화"):
682
- conv["title"] = conversation_title(item)
683
-
684
- return sorted((conversations[key] for key in order), key=lambda item: item.get("updated_at") or "", reverse=True)
685
-
686
- def get_conversation_messages(conversation_id: str) -> List[Dict]:
687
- history = get_history()
688
- if conversation_id == "legacy-previous-history":
689
- return [item for item in history if not item.get("conversation_id")]
690
- return [item for item in history if item.get("conversation_id") == conversation_id]
691
-
692
- def clear_history(keep_last: int = 0) -> Dict:
693
- return CONVERSATIONS.clear_all(keep_last=keep_last)
694
-
695
- def clear_conversation(conversation_id: str, started_at: Optional[str] = None) -> Dict:
696
- return CONVERSATIONS.clear_conversation(conversation_id, started_at=started_at)
697
-
698
586
  _access_runtime = build_access_runtime(
699
587
  config=CONFIG,
700
588
  require_auth=REQUIRE_AUTH,
@@ -721,136 +609,24 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
721
609
  def _bytes_match_extension(data: bytes, ext: str) -> bool:
722
610
  return _bytes_match_extension_impl(data, ext)
723
611
 
724
- _LOCAL_APPROVAL_TTL_SECONDS = 5 * 60
725
- _local_approvals: Dict[str, Dict[str, object]] = {}
726
-
727
-
728
- def _normalize_local_path_for_approval(path: str) -> str:
729
- return str(Path(path).expanduser().resolve())
730
-
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.
731
617
 
732
- def _content_fingerprint(content: str = "") -> str:
733
- return hashlib.sha256(content.encode("utf-8")).hexdigest()
734
-
735
-
736
- def _local_permission_response(path: str, action: str, user_email: str, content: str = "") -> dict:
737
- normalized = _normalize_local_path_for_approval(path)
738
- token = secrets.token_urlsafe(24)
739
- record: Dict[str, object] = {
740
- "path": normalized,
741
- "action": action,
742
- "user_email": user_email,
743
- "expires_at": time.time() + _LOCAL_APPROVAL_TTL_SECONDS,
744
- "approved": False,
745
- }
746
- if action == "write":
747
- record["content_hash"] = _content_fingerprint(content)
748
- _local_approvals[token] = record
749
- return {
750
- "permission_required": True,
751
- "path": path,
752
- "action": action,
753
- "approval_token": token,
754
- "expires_in": _LOCAL_APPROVAL_TTL_SECONDS,
755
- }
756
-
757
-
758
- def _require_local_approval(
759
- *,
760
- token: Optional[str],
761
- path: str,
762
- action: str,
763
- user_email: str,
764
- content: str = "",
765
- ) -> None:
766
- if not token:
767
- raise HTTPException(status_code=403, detail="파일 접근 승인 토큰이 필요합니다.")
768
- record = _local_approvals.get(token)
769
- if not record or float(record.get("expires_at", 0)) < time.time():
770
- raise HTTPException(status_code=403, detail="파일 접근 승인이 만료되었거나 유효하지 않습니다.")
771
- if not record.get("approved"):
772
- raise HTTPException(status_code=403, detail="파일 접근이 아직 승인되지 않았습니다.")
773
- if record.get("user_email") != user_email:
774
- raise HTTPException(status_code=403, detail="다른 사용자의 파일 접근 승인은 사용할 수 없습니다.")
775
- if record.get("path") != _normalize_local_path_for_approval(path) or record.get("action") != action:
776
- raise HTTPException(status_code=403, detail="파일 접근 승인 범위가 일치하지 않습니다.")
777
- if action == "write" and record.get("content_hash") != _content_fingerprint(content):
778
- raise HTTPException(status_code=403, detail="승인된 파일 내용과 요청 내용이 다릅니다.")
779
-
780
-
781
- def get_history_user(email: Optional[str], nickname: Optional[str] = None) -> Dict:
782
- if not email:
783
- return {"user_email": None, "user_nickname": nickname or None}
784
- users = load_users()
785
- user = users.get(email, {})
786
- return {
787
- "user_email": email,
788
- "user_nickname": nickname or user.get("nickname") or user.get("name") or email,
789
- }
790
-
791
- def get_user_api_key(email: Optional[str], provider: str) -> Optional[str]:
792
- if not email:
793
- return None
794
- keyring_key = f"{email}:{provider}"
795
- if keyring is not None:
796
- try:
797
- key = keyring.get_password("LatticeAI", keyring_key)
798
- if key:
799
- return key.strip()
800
- except Exception as exc:
801
- logging.warning("keyring read failed for %s: %s", provider, exc)
802
- users = load_users()
803
- user = users.get(email) or {}
804
- api_keys = user.get("api_keys") or {}
805
- key = api_keys.get(provider)
806
- if isinstance(key, str) and key.strip() and ALLOW_PLAINTEXT_API_KEYS:
807
- return key.strip()
808
- return None
809
-
810
- def set_user_api_key(email: str, provider: str, key: str) -> None:
811
- keyring_key = f"{email}:{provider}"
812
- if keyring is not None:
813
- try:
814
- keyring.set_password("LatticeAI", keyring_key, key)
815
- users = load_users()
816
- user = users.get(email)
817
- if user and "api_keys" in user:
818
- user["api_keys"].pop(provider, None)
819
- if not user["api_keys"]:
820
- user.pop("api_keys", None)
821
- save_users(users)
822
- return
823
- except Exception as exc:
824
- logging.warning("keyring write failed for %s: %s", provider, exc)
825
- if not ALLOW_PLAINTEXT_API_KEYS:
826
- raise HTTPException(
827
- status_code=500,
828
- detail="OS keyring에 API 키를 저장하지 못했습니다. keyring 설정을 확인하거나 LATTICEAI_ALLOW_PLAINTEXT_API_KEYS=true를 명시적으로 설정하세요.",
829
- )
830
-
831
- if not ALLOW_PLAINTEXT_API_KEYS:
832
- raise HTTPException(
833
- status_code=500,
834
- detail="keyring 패키지를 사용할 수 없어 API 키를 안전하게 저장할 수 없습니다.",
835
- )
836
-
837
- users = load_users()
838
- user = users.get(email)
839
- if not user:
840
- user = {
841
- "password_hash": "",
842
- "salt": "",
843
- "name": email,
844
- "nickname": email,
845
- "role": "user",
846
- "disabled": False,
847
- }
848
- ensure_user_identity(email, user)
849
- api_keys = user.get("api_keys") or {}
850
- api_keys[provider] = key
851
- user["api_keys"] = api_keys
852
- users[email] = user
853
- 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"]
854
630
 
855
631
  # ── Sensitivity analysis — delegated to latticeai.core.audit ──────────────────
856
632
  def classify_sensitive_message(item: Dict, index: int) -> Dict:
@@ -942,6 +718,8 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
942
718
  ENABLE_GRAPH=ENABLE_GRAPH,
943
719
  AUTOLOAD_MODELS=AUTOLOAD_MODELS,
944
720
  MODEL_IDLE_UNLOAD_SECONDS=MODEL_IDLE_UNLOAD_SECONDS,
721
+ ALLOW_MODEL_DOWNLOADS=ALLOW_MODEL_DOWNLOADS,
722
+ MODEL_DOWNLOAD_TIMEOUT=MODEL_DOWNLOAD_TIMEOUT,
945
723
  ALLOW_LOCAL_MODELS=ALLOW_LOCAL_MODELS,
946
724
  REQUIRE_AUTH=REQUIRE_AUTH,
947
725
  INVITE_GATE_ENABLED=INVITE_GATE_ENABLED,
@@ -1136,6 +914,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1136
914
  load_users=load_users,
1137
915
  get_user_role=get_user_role,
1138
916
  enforce_rate_limit=enforce_rate_limit,
917
+ allowed_workspaces_for=_history_allowed_workspaces_for,
1139
918
  append_audit_event=append_audit_event,
1140
919
  get_audit_log=get_audit_log,
1141
920
  get_history=get_history,
@@ -1378,9 +1157,34 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1378
1157
  uvicorn.run(app, host=DEFAULT_HOST, port=DEFAULT_PORT, log_level="info")
1379
1158
 
1380
1159
  # ── Constructed-namespace export (consumed by AppRuntime) ────────────────
1381
- # Every local singletons, helper functions, request models becomes an
1382
- # attribute of the runtime so the legacy ``server_app`` surface survives.
1383
- 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)
1178
+
1179
+
1180
+ @dataclass(frozen=True)
1181
+ class LegacyRuntimeNamespace:
1182
+ """Compatibility adapter for the historical module-level runtime surface."""
1183
+
1184
+ namespace: Dict[str, Any]
1185
+
1186
+ def bind(self, runtime: "AppRuntime") -> None:
1187
+ runtime.__dict__.update(self.namespace)
1384
1188
 
1385
1189
 
1386
1190
  class AppRuntime:
@@ -1391,7 +1195,8 @@ class AppRuntime:
1391
1195
  """
1392
1196
 
1393
1197
  def __init__(self, namespace: Dict[str, Any]) -> None:
1394
- self.__dict__.update(namespace)
1198
+ self._legacy_namespace = LegacyRuntimeNamespace(namespace)
1199
+ self._legacy_namespace.bind(self)
1395
1200
 
1396
1201
 
1397
1202
  _runtime_lock = threading.RLock()
@@ -52,7 +52,7 @@ class AgentRunContext:
52
52
  """Mutable state carrier passed through all agent phases."""
53
53
  __slots__ = ("state", "plan", "transcript", "retry_count",
54
54
  "state_history", "corrections", "final_message", "rollback_log",
55
- "executing_model", "reviewing_model")
55
+ "executing_model", "reviewing_model", "approved_by_human")
56
56
 
57
57
  def __init__(self) -> None:
58
58
  self.state: AgentState = AgentState.IDLE
@@ -65,6 +65,7 @@ class AgentRunContext:
65
65
  self.rollback_log: list = []
66
66
  self.executing_model: Optional[str] = None
67
67
  self.reviewing_model: Optional[str] = None
68
+ self.approved_by_human: bool = False
68
69
 
69
70
 
70
71
  def extract_action(raw: str) -> Dict:
@@ -207,7 +208,7 @@ class SingleAgentRuntime:
207
208
  ctx.state = AgentState.WAITING_APPROVAL
208
209
 
209
210
  # ── APPROVAL ─────────────────────────────────────────────────────
210
- def approve(self, ctx: AgentRunContext, current_user: str) -> None:
211
+ def approve(self, ctx: AgentRunContext, current_user: str, *, approved_by_human: bool = False) -> None:
211
212
  """APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
212
213
  d = self.deps
213
214
  auto_approve_tools = {name for name, p in d.tool_governance.items() if p["auto_approve"]}
@@ -219,12 +220,22 @@ class SingleAgentRuntime:
219
220
  "state": AgentState.WAITING_APPROVAL.value,
220
221
  "requires_approval": requires,
221
222
  "non_auto_approve_steps": non_auto,
222
- "decision": "auto_approved",
223
+ "decision": "human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved"),
223
224
  })
224
225
  d.audit(
225
226
  "agent_approval", user_email=current_user,
226
- requires_approval=requires, non_auto_steps=non_auto, decision="auto_approved",
227
+ requires_approval=requires,
228
+ non_auto_steps=non_auto,
229
+ decision="human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved"),
227
230
  )
231
+ if requires and not approved_by_human:
232
+ ctx.final_message = (
233
+ "이 작업에는 명시 승인이 필요한 도구가 포함되어 있어 자동 실행을 중단했습니다. "
234
+ "human_in_loop 승인 흐름으로 다시 실행해 주세요."
235
+ )
236
+ ctx.state = AgentState.FAILED
237
+ return
238
+ ctx.approved_by_human = bool(approved_by_human)
228
239
  ctx.state = AgentState.EXECUTING
229
240
 
230
241
  # ── EXECUTE ──────────────────────────────────────────────────────
@@ -317,7 +328,7 @@ class SingleAgentRuntime:
317
328
  )
318
329
  continue
319
330
 
320
- if not policy["auto_approve"]:
331
+ if not policy["auto_approve"] and not ctx.approved_by_human:
321
332
  d.audit(
322
333
  "agent_exec", user_email=current_user, source=getattr(req, "source", None) or "agent",
323
334
  state=AgentState.EXECUTING.value, action=name, risk=risk,
@@ -326,6 +337,13 @@ class SingleAgentRuntime:
326
337
  rollback=policy["rollback"],
327
338
  args={k: v for k, v in args.items() if k != "content"},
328
339
  )
340
+ ctx.transcript.append({
341
+ "state": AgentState.EXECUTING.value, "action": name,
342
+ "thoughts": thoughts, "args": args, "risk": risk,
343
+ "governance": dict(policy),
344
+ "error": f"BLOCKED: action '{name}' requires explicit approval.",
345
+ })
346
+ continue
329
347
 
330
348
  try:
331
349
  d.check_role(name, current_user)
@@ -422,8 +440,8 @@ class SingleAgentRuntime:
422
440
  if gov.get("rollback") != "git":
423
441
  continue
424
442
  result = step.get("result", {})
425
- if not (isinstance(result, dict) and result.get("success")):
426
- continue
443
+ if not isinstance(result, dict):
444
+ result = {}
427
445
  path = result.get("path") or (step.get("args") or {}).get("path", "")
428
446
  if not path:
429
447
  continue
@@ -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.8.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