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.
- package/README.md +33 -37
- package/auto_setup.py +84 -70
- package/docs/CHANGELOG.md +113 -237
- package/docs/CODE_REVIEW_2026-07-06.md +764 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
- package/docs/DEVELOPMENT.md +10 -10
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
- package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
- package/docs/TRUST_MODEL.md +5 -1
- package/docs/WHY_LATTICE.md +4 -3
- package/docs/architecture.md +4 -0
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +4 -9
- package/lattice_brain/conversations.py +156 -21
- package/lattice_brain/embeddings.py +38 -2
- package/lattice_brain/graph/_kg_common.py +39 -496
- 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/json_utils.py +25 -0
- package/lattice_brain/graph/retrieval.py +66 -597
- package/lattice_brain/graph/retrieval_docgen.py +210 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/runtime.py +16 -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 +368 -418
- 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/mcp.py +3 -2
- package/latticeai/api/models.py +4 -1
- package/latticeai/api/permissions.py +72 -33
- package/latticeai/api/setup.py +17 -2
- package/latticeai/api/tools.py +105 -62
- package/latticeai/app_factory.py +101 -296
- package/latticeai/core/agent.py +25 -7
- 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/sessions.py +11 -3
- package/latticeai/core/tool_registry.py +15 -4
- 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/bootstrap.py +1 -1
- 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/app_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +2 -2
- package/latticeai/services/memory_service.py +213 -0
- package/latticeai/services/model_engines.py +79 -12
- package/latticeai/services/model_runtime.py +24 -4
- package/latticeai/services/platform_runtime.py +9 -1
- package/latticeai/services/process_audit.py +208 -0
- 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/search_service.py +106 -30
- package/latticeai/services/setup_detection.py +80 -0
- package/latticeai/services/tool_dispatch.py +66 -0
- package/latticeai/services/workspace_service.py +15 -0
- package/package.json +1 -1
- package/scripts/check_i18n_literals.mjs +20 -8
- package/scripts/i18n_literal_allowlist.json +34 -0
- package/scripts/lint_frontend.mjs +6 -2
- package/setup_wizard.py +196 -74
- 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-C7K9wsO9.js → Act-21lIXx2E.js} +1 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
- package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-BA7Z2Q1u.js} +1 -1
- package/static/app/assets/{Library-Cgj-EF50.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-CVQkN2Tk.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-I1OSzxJu.js +0 -321
- package/static/app/assets/System-D1Lkei3I.js +0 -1
- package/static/app/assets/index--P0ksosz.js +0 -17
- package/static/app/assets/index-DCh5AoXt.css +0 -2
- package/static/app/assets/primitives-BLqaKk5g.js +0 -1
package/latticeai/app_factory.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
256
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
#
|
|
1382
|
-
#
|
|
1383
|
-
|
|
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.
|
|
1198
|
+
self._legacy_namespace = LegacyRuntimeNamespace(namespace)
|
|
1199
|
+
self._legacy_namespace.bind(self)
|
|
1395
1200
|
|
|
1396
1201
|
|
|
1397
1202
|
_runtime_lock = threading.RLock()
|
package/latticeai/core/agent.py
CHANGED
|
@@ -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,
|
|
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
|
|
426
|
-
|
|
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"]
|
|
@@ -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
|
|