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.
- package/README.md +28 -39
- package/auto_setup.py +11 -62
- package/docs/CHANGELOG.md +76 -237
- package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
- package/docs/DEVELOPMENT.md +8 -8
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +2 -2
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +4 -9
- package/lattice_brain/embeddings.py +38 -2
- package/lattice_brain/graph/_kg_common.py +27 -462
- package/lattice_brain/graph/_kg_constants.py +243 -0
- package/lattice_brain/graph/_kg_fsutil.py +297 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +972 -0
- package/lattice_brain/graph/retrieval.py +0 -570
- package/lattice_brain/graph/retrieval_docgen.py +210 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/store.py +6 -0
- package/lattice_brain/ingestion.py +68 -0
- package/lattice_brain/portability.py +1 -9
- package/lattice_brain/quality.py +98 -4
- package/lattice_brain/runtime/agent_runtime.py +166 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/utils.py +28 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +340 -407
- package/latticeai/api/chat_helpers.py +227 -0
- package/latticeai/api/computer_use.py +149 -31
- package/latticeai/api/marketplace.py +11 -0
- package/latticeai/api/permissions.py +3 -3
- package/latticeai/api/tools.py +1 -0
- package/latticeai/app_factory.py +82 -360
- package/latticeai/core/io_utils.py +37 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/local_embeddings.py +2 -4
- package/latticeai/core/marketplace.py +33 -2
- package/latticeai/core/mcp_catalog.py +450 -0
- package/latticeai/core/mcp_registry.py +2 -441
- package/latticeai/core/users.py +4 -9
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/core/workspace_os_utils.py +3 -17
- package/latticeai/integrations/telegram_bot.py +7 -2
- package/latticeai/models/model_providers.py +111 -0
- package/latticeai/models/router.py +58 -136
- package/latticeai/runtime/audit_runtime.py +27 -16
- package/latticeai/runtime/automation_runtime.py +9 -0
- package/latticeai/runtime/history_runtime.py +163 -0
- package/latticeai/runtime/namespace_runtime.py +173 -0
- package/latticeai/runtime/network_config_runtime.py +56 -0
- package/latticeai/runtime/sso_config_runtime.py +128 -0
- package/latticeai/runtime/user_key_runtime.py +106 -0
- package/latticeai/services/architecture_readiness.py +2 -2
- package/latticeai/services/memory_service.py +213 -0
- package/latticeai/services/platform_runtime.py +9 -1
- package/latticeai/services/product_readiness.py +11 -11
- package/latticeai/services/review_queue.py +64 -11
- package/latticeai/services/run_executor.py +21 -0
- package/latticeai/services/setup_detection.py +80 -0
- package/latticeai/services/tool_dispatch.py +1 -1
- package/package.json +1 -1
- package/setup_wizard.py +11 -55
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/{Act-fZokUnC0.js → Act-21lIXx2E.js} +1 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
- package/static/app/assets/{Capture-D5KV3Cu7.js → Capture-BA7Z2Q1u.js} +1 -1
- package/static/app/assets/{Library-C9kyFkSt.js → Library-bFMtyni3.js} +1 -1
- package/static/app/assets/System-K6krGCqn.js +1 -0
- package/static/app/assets/index-C4R3ws30.js +17 -0
- package/static/app/assets/index-ChSeOB02.css +2 -0
- package/static/app/assets/primitives-sQU3it5I.js +1 -0
- package/static/app/assets/{textarea-CD8UNKIy.js → textarea-DK3Fd_lR.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/css/tokens.css +4 -2
- package/static/sw.js +1 -1
- package/tools/local_files.py +6 -0
- package/latticeai/runtime/sso_runtime.py +0 -52
- package/static/app/assets/Brain-DtyuWubr.js +0 -321
- package/static/app/assets/System-VbChmX7r.js +0 -1
- package/static/app/assets/index-DCh5AoXt.css +0 -2
- package/static/app/assets/index-DPdcPoF0.js +0 -17
- package/static/app/assets/primitives-DFeanEV6.js +0 -1
package/latticeai/app_factory.py
CHANGED
|
@@ -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.
|
|
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
|
|
259
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
#
|
|
1454
|
-
#
|
|
1455
|
-
|
|
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"]
|
|
@@ -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
|
-
|
|
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 = "
|
|
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
|
|