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
@@ -26,7 +26,8 @@ def env_value(primary: str, default: str = "") -> str:
26
26
 
27
27
  TOKEN = env_value("LATTICEAI_TELEGRAM_BOT_TOKEN")
28
28
  API_URL = f"https://api.telegram.org/bot{TOKEN}"
29
- BASE_URL = "http://127.0.0.1:4825"
29
+ SERVER_PORT = int(env_value("LATTICEAI_SERVER_PORT", "4825"))
30
+ BASE_URL = env_value("LATTICEAI_BASE_URL", env_value("LATTICEAI_SERVER_URL", f"http://127.0.0.1:{SERVER_PORT}")).rstrip("/")
30
31
  CHAT_URL = f"{BASE_URL}/chat"
31
32
  AGENT_URL = f"{BASE_URL}/agent"
32
33
  MCP_TOOLS_URL = f"{BASE_URL}/mcp/tools"
@@ -42,7 +43,6 @@ AGENT_WORKSPACE = Path(env_value("LATTICEAI_AGENT_ROOT", "agent_workspace"
42
43
  # Pending plan approvals: context_id → (chat_id, executing_model, reviewing_model)
43
44
  _bot_pending_plans: dict[str, dict] = {}
44
45
  MAX_TELEGRAM_FILE_BYTES = 45 * 1024 * 1024
45
- SERVER_PORT = int(env_value("LATTICEAI_SERVER_PORT", "4825"))
46
46
  INVITE_CODE = env_value("LATTICEAI_INVITE_CODE", "gemma-lattice-ai")
47
47
  PUBLIC_WEB_URL = env_value("LATTICEAI_PUBLIC_URL")
48
48
  DATA_DIR = Path(env_value("LATTICEAI_DATA_DIR", str(Path.home() / ".ltcai")))
@@ -56,6 +56,9 @@ logger = logging.getLogger(__name__)
56
56
 
57
57
  def _get_server_session() -> str:
58
58
  """Read the most recent valid admin session from sessions.json (web login)."""
59
+ explicit_token = env_value("LATTICEAI_SERVER_SESSION_TOKEN")
60
+ if explicit_token:
61
+ return explicit_token
59
62
  sessions_file = DATA_DIR / "sessions.json"
60
63
  users_file = DATA_DIR / "users.json"
61
64
  try:
@@ -70,6 +73,8 @@ def _get_server_session() -> str:
70
73
  # Pick the newest non-expired admin session
71
74
  best_token, best_ts = "", 0.0
72
75
  for token, entry in sessions.items():
76
+ if len(token) == 64 and all(ch in "0123456789abcdef" for ch in token.lower()):
77
+ continue
73
78
  email, created_at = entry[0], float(entry[1])
74
79
  if admin_emails and email not in admin_emails:
75
80
  continue
@@ -0,0 +1,111 @@
1
+ """Cloud LLM provider catalog — static data split out of the router.
2
+
3
+ Pure config dicts (OpenAI-compatible providers, per-provider model
4
+ catalog, model→source family map). router.py re-exports these, so
5
+ ``from latticeai.models.router import OPENAI_COMPATIBLE_PROVIDERS`` and the
6
+ model_runtime re-export chain are unaffected.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ OPENAI_COMPATIBLE_PROVIDERS = {
12
+ "openai": {
13
+ "env_key": "OPENAI_API_KEY",
14
+ "base_url_env": "OPENAI_BASE_URL",
15
+ "default_model": "gpt-4o-mini",
16
+ },
17
+ "openrouter": {
18
+ "env_key": "OPENROUTER_API_KEY",
19
+ "base_url": "https://openrouter.ai/api/v1",
20
+ "default_model": "openai/gpt-4o-mini",
21
+ },
22
+ "groq": {
23
+ "env_key": "GROQ_API_KEY",
24
+ "base_url": "https://api.groq.com/openai/v1",
25
+ "default_model": "meta-llama/llama-4-scout-17b-16e-instruct",
26
+ },
27
+ "together": {
28
+ "env_key": "TOGETHER_API_KEY",
29
+ "base_url": "https://api.together.xyz/v1",
30
+ "default_model": "Qwen/Qwen3-VL-32B-Instruct",
31
+ },
32
+ "xai": {
33
+ "env_key": "XAI_API_KEY",
34
+ "base_url": "https://api.x.ai/v1",
35
+ "default_model": "grok-beta",
36
+ },
37
+ "ollama": {
38
+ "env_key": "OLLAMA_API_KEY",
39
+ "base_url_env": "OLLAMA_BASE_URL",
40
+ "base_url": "http://localhost:11434/v1",
41
+ "default_model": "hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_K_M",
42
+ "api_key_fallback": "ollama",
43
+ },
44
+ "vllm": {
45
+ "env_key": "VLLM_API_KEY",
46
+ "base_url_env": "VLLM_BASE_URL",
47
+ "base_url": "http://localhost:8000/v1",
48
+ "default_model": "Qwen/Qwen3-VL-8B-Instruct",
49
+ "api_key_fallback": "vllm",
50
+ },
51
+ "lmstudio": {
52
+ "env_key": "LMSTUDIO_API_KEY",
53
+ "base_url_env": "LMSTUDIO_BASE_URL",
54
+ "base_url": "http://localhost:1234/v1",
55
+ "default_model": "local-model",
56
+ "api_key_fallback": "lmstudio",
57
+ },
58
+ "llamacpp": {
59
+ "env_key": "LLAMACPP_API_KEY",
60
+ "base_url_env": "LLAMACPP_BASE_URL",
61
+ "base_url": "http://localhost:8080/v1",
62
+ "default_model": "llama.cpp-model",
63
+ "api_key_fallback": "llamacpp",
64
+ },
65
+ }
66
+
67
+ PROVIDER_MODEL_CATALOG = {
68
+ "openai": [
69
+ {"id": "gpt-5.5", "name": "GPT-5.5", "family": "GPT"},
70
+ {"id": "gpt-5.4", "name": "GPT-5.4", "family": "GPT"},
71
+ {"id": "gpt-5.4-mini", "name": "GPT-5.4 Mini", "family": "GPT"},
72
+ {"id": "gpt-5.4-nano", "name": "GPT-5.4 Nano", "family": "GPT"},
73
+ {"id": "gpt-4o-mini", "name": "GPT-4o Mini", "family": "GPT"},
74
+ {"id": "gpt-4o", "name": "GPT-4o", "family": "GPT"},
75
+ {"id": "gpt-4.1-mini", "name": "GPT-4.1 Mini", "family": "GPT"},
76
+ {"id": "gpt-4.1", "name": "GPT-4.1", "family": "GPT"},
77
+ ],
78
+ "openrouter": [
79
+ {"id": "openai/gpt-5.5", "name": "GPT-5.5 via OpenRouter", "family": "GPT"},
80
+ {"id": "openai/gpt-4o-mini", "name": "GPT-4o Mini via OpenRouter", "family": "GPT"},
81
+ {"id": "anthropic/claude-opus-4.7", "name": "Claude Opus 4.7 via OpenRouter", "family": "Claude"},
82
+ {"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6 via OpenRouter", "family": "Claude"},
83
+ {"id": "anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5 via OpenRouter", "family": "Claude"},
84
+ {"id": "qwen/qwen3-vl-235b-a22b-instruct", "name": "Qwen3-VL 235B A22B via OpenRouter", "family": "Qwen"},
85
+ {"id": "google/gemma-4-12b-it", "name": "Gemma 4 12B via OpenRouter", "family": "Gemma"},
86
+ {"id": "x-ai/grok-2", "name": "Grok 2 via OpenRouter", "family": "Grok"},
87
+ {"id": "meta-llama/llama-4-scout-17b-16e-instruct", "name": "Llama 4 Scout via OpenRouter", "family": "Llama"},
88
+ {"id": "google/gemini-2.5-flash", "name": "Gemini 2.5 Flash via OpenRouter", "family": "Gemini"},
89
+ ],
90
+ "groq": [
91
+ {"id": "meta-llama/llama-4-scout-17b-16e-instruct", "name": "Llama 4 Scout", "family": "Llama"},
92
+ ],
93
+ "together": [
94
+ {"id": "Qwen/Qwen3-VL-32B-Instruct", "name": "Qwen3-VL 32B", "family": "Qwen"},
95
+ {"id": "google/gemma-4-12b-it", "name": "Gemma 4 12B", "family": "Gemma"},
96
+ {"id": "meta-llama/Llama-4-Scout-17B-16E-Instruct", "name": "Llama 4 Scout", "family": "Llama"},
97
+ ],
98
+ "xai": [
99
+ {"id": "grok-beta", "name": "Grok Beta", "family": "Grok"},
100
+ {"id": "grok-vision-beta", "name": "Grok Vision Beta", "family": "Grok"},
101
+ ],
102
+ }
103
+
104
+ MODEL_SOURCE_BY_FAMILY = {
105
+ "GPT": ("미국", "OpenAI"),
106
+ "Claude": ("미국", "Anthropic"),
107
+ "Qwen": ("중국", "Alibaba"),
108
+ "Llama": ("미국", "Meta"),
109
+ "Gemini": ("미국", "Google"),
110
+ "Grok": ("미국", "xAI"),
111
+ }
@@ -9,6 +9,7 @@ import io
9
9
  import json
10
10
  import os
11
11
  import re
12
+ import threading
12
13
  import time
13
14
  from dataclasses import dataclass
14
15
  from pathlib import Path
@@ -21,6 +22,15 @@ from concurrent.futures import ThreadPoolExecutor
21
22
  from typing import AsyncIterator, Dict, Optional, Tuple, List
22
23
  from PIL import Image
23
24
 
25
+ # Cloud provider catalog data lives in .model_providers; re-exported here so
26
+ # ``from latticeai.models.router import OPENAI_COMPATIBLE_PROVIDERS`` (and the
27
+ # model_runtime re-export chain) resolve unchanged after the split.
28
+ from latticeai.models.model_providers import ( # noqa: F401
29
+ MODEL_SOURCE_BY_FAMILY,
30
+ OPENAI_COMPATIBLE_PROVIDERS,
31
+ PROVIDER_MODEL_CATALOG,
32
+ )
33
+
24
34
  try:
25
35
  from openai import AsyncOpenAI
26
36
  except Exception:
@@ -76,108 +86,6 @@ def normalize_branding(text: Optional[str]) -> str:
76
86
  normalized = pattern.sub(replacement, normalized)
77
87
  return normalized
78
88
 
79
- OPENAI_COMPATIBLE_PROVIDERS = {
80
- "openai": {
81
- "env_key": "OPENAI_API_KEY",
82
- "base_url_env": "OPENAI_BASE_URL",
83
- "default_model": "gpt-4o-mini",
84
- },
85
- "openrouter": {
86
- "env_key": "OPENROUTER_API_KEY",
87
- "base_url": "https://openrouter.ai/api/v1",
88
- "default_model": "openai/gpt-4o-mini",
89
- },
90
- "groq": {
91
- "env_key": "GROQ_API_KEY",
92
- "base_url": "https://api.groq.com/openai/v1",
93
- "default_model": "meta-llama/llama-4-scout-17b-16e-instruct",
94
- },
95
- "together": {
96
- "env_key": "TOGETHER_API_KEY",
97
- "base_url": "https://api.together.xyz/v1",
98
- "default_model": "Qwen/Qwen3-VL-32B-Instruct",
99
- },
100
- "xai": {
101
- "env_key": "XAI_API_KEY",
102
- "base_url": "https://api.x.ai/v1",
103
- "default_model": "grok-beta",
104
- },
105
- "ollama": {
106
- "env_key": "OLLAMA_API_KEY",
107
- "base_url_env": "OLLAMA_BASE_URL",
108
- "base_url": "http://localhost:11434/v1",
109
- "default_model": "hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_K_M",
110
- "api_key_fallback": "ollama",
111
- },
112
- "vllm": {
113
- "env_key": "VLLM_API_KEY",
114
- "base_url_env": "VLLM_BASE_URL",
115
- "base_url": "http://localhost:8000/v1",
116
- "default_model": "Qwen/Qwen3-VL-8B-Instruct",
117
- "api_key_fallback": "vllm",
118
- },
119
- "lmstudio": {
120
- "env_key": "LMSTUDIO_API_KEY",
121
- "base_url_env": "LMSTUDIO_BASE_URL",
122
- "base_url": "http://localhost:1234/v1",
123
- "default_model": "local-model",
124
- "api_key_fallback": "lmstudio",
125
- },
126
- "llamacpp": {
127
- "env_key": "LLAMACPP_API_KEY",
128
- "base_url_env": "LLAMACPP_BASE_URL",
129
- "base_url": "http://localhost:8080/v1",
130
- "default_model": "llama.cpp-model",
131
- "api_key_fallback": "llamacpp",
132
- },
133
- }
134
-
135
- PROVIDER_MODEL_CATALOG = {
136
- "openai": [
137
- {"id": "gpt-5.5", "name": "GPT-5.5", "family": "GPT"},
138
- {"id": "gpt-5.4", "name": "GPT-5.4", "family": "GPT"},
139
- {"id": "gpt-5.4-mini", "name": "GPT-5.4 Mini", "family": "GPT"},
140
- {"id": "gpt-5.4-nano", "name": "GPT-5.4 Nano", "family": "GPT"},
141
- {"id": "gpt-4o-mini", "name": "GPT-4o Mini", "family": "GPT"},
142
- {"id": "gpt-4o", "name": "GPT-4o", "family": "GPT"},
143
- {"id": "gpt-4.1-mini", "name": "GPT-4.1 Mini", "family": "GPT"},
144
- {"id": "gpt-4.1", "name": "GPT-4.1", "family": "GPT"},
145
- ],
146
- "openrouter": [
147
- {"id": "openai/gpt-5.5", "name": "GPT-5.5 via OpenRouter", "family": "GPT"},
148
- {"id": "openai/gpt-4o-mini", "name": "GPT-4o Mini via OpenRouter", "family": "GPT"},
149
- {"id": "anthropic/claude-opus-4.7", "name": "Claude Opus 4.7 via OpenRouter", "family": "Claude"},
150
- {"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6 via OpenRouter", "family": "Claude"},
151
- {"id": "anthropic/claude-haiku-4.5", "name": "Claude Haiku 4.5 via OpenRouter", "family": "Claude"},
152
- {"id": "qwen/qwen3-vl-235b-a22b-instruct", "name": "Qwen3-VL 235B A22B via OpenRouter", "family": "Qwen"},
153
- {"id": "google/gemma-4-12b-it", "name": "Gemma 4 12B via OpenRouter", "family": "Gemma"},
154
- {"id": "x-ai/grok-2", "name": "Grok 2 via OpenRouter", "family": "Grok"},
155
- {"id": "meta-llama/llama-4-scout-17b-16e-instruct", "name": "Llama 4 Scout via OpenRouter", "family": "Llama"},
156
- {"id": "google/gemini-2.5-flash", "name": "Gemini 2.5 Flash via OpenRouter", "family": "Gemini"},
157
- ],
158
- "groq": [
159
- {"id": "meta-llama/llama-4-scout-17b-16e-instruct", "name": "Llama 4 Scout", "family": "Llama"},
160
- ],
161
- "together": [
162
- {"id": "Qwen/Qwen3-VL-32B-Instruct", "name": "Qwen3-VL 32B", "family": "Qwen"},
163
- {"id": "google/gemma-4-12b-it", "name": "Gemma 4 12B", "family": "Gemma"},
164
- {"id": "meta-llama/Llama-4-Scout-17B-16E-Instruct", "name": "Llama 4 Scout", "family": "Llama"},
165
- ],
166
- "xai": [
167
- {"id": "grok-beta", "name": "Grok Beta", "family": "Grok"},
168
- {"id": "grok-vision-beta", "name": "Grok Vision Beta", "family": "Grok"},
169
- ],
170
- }
171
-
172
- MODEL_SOURCE_BY_FAMILY = {
173
- "GPT": ("미국", "OpenAI"),
174
- "Claude": ("미국", "Anthropic"),
175
- "Qwen": ("중국", "Alibaba"),
176
- "Llama": ("미국", "Meta"),
177
- "Gemini": ("미국", "Google"),
178
- "Grok": ("미국", "xAI"),
179
- }
180
-
181
89
 
182
90
  def source_metadata_for_model(provider: str, model: Dict[str, str], *, local_server: bool) -> Dict[str, str]:
183
91
  family = str(model.get("family") or "")
@@ -344,6 +252,12 @@ class LLMRouter:
344
252
  self._current: Optional[str] = None
345
253
  self._last_used: Dict[str, float] = {}
346
254
  self._max_local_models = max(1, int(os.getenv("LATTICEAI_MAX_LOCAL_MODELS", "1")))
255
+ # Guards the mutable model registry (_cache/_current/_last_used).
256
+ # Reentrant because the eviction path nests: _enforce_local_model_limit
257
+ # → unload_model → _release_memory. Never held across the heavy
258
+ # ``run_in_executor`` load (only the sync insert/read is guarded), so a
259
+ # long model load can't block a concurrent switch/unload from acquiring.
260
+ self._lock = threading.RLock()
347
261
 
348
262
  @property
349
263
  def current_model_id(self) -> Optional[str]:
@@ -354,33 +268,37 @@ class LLMRouter:
354
268
  return list(self._cache.keys())
355
269
 
356
270
  def switch_model(self, model_id: str) -> None:
357
- if model_id not in self._cache:
358
- raise KeyError(model_id)
359
- self._current = model_id
360
- self._touch(model_id)
271
+ with self._lock:
272
+ if model_id not in self._cache:
273
+ raise KeyError(model_id)
274
+ self._current = model_id
275
+ self._touch(model_id)
361
276
 
362
277
  def unload_model(self, model_id: str) -> None:
363
- self._cache.pop(model_id, None)
364
- self._last_used.pop(model_id, None)
365
- if self._current == model_id:
366
- self._current = next(iter(self._cache), None)
367
- self._release_memory()
278
+ with self._lock:
279
+ self._cache.pop(model_id, None)
280
+ self._last_used.pop(model_id, None)
281
+ if self._current == model_id:
282
+ self._current = next(iter(self._cache), None)
283
+ self._release_memory()
368
284
 
369
285
  def unload_all(self) -> None:
370
- self._cache.clear()
371
- self._last_used.clear()
372
- self._current = None
373
- self._release_memory()
286
+ with self._lock:
287
+ self._cache.clear()
288
+ self._last_used.clear()
289
+ self._current = None
290
+ self._release_memory()
374
291
 
375
292
  def unload_idle_models(self, idle_seconds: int) -> List[str]:
376
293
  if idle_seconds <= 0:
377
294
  return []
378
295
  now = time.monotonic()
379
296
  unloaded = []
380
- for model_id, last_used in list(self._last_used.items()):
381
- if now - last_used >= idle_seconds:
382
- self.unload_model(model_id)
383
- unloaded.append(model_id)
297
+ with self._lock:
298
+ for model_id, last_used in list(self._last_used.items()):
299
+ if now - last_used >= idle_seconds:
300
+ self.unload_model(model_id)
301
+ unloaded.append(model_id)
384
302
  return unloaded
385
303
 
386
304
  def model_memory_policy(self) -> Dict[str, object]:
@@ -400,14 +318,15 @@ class LLMRouter:
400
318
  return cached is not None and not isinstance(cached, CloudModel)
401
319
 
402
320
  def _enforce_local_model_limit(self, incoming_key: str) -> None:
403
- local_ids = [model_id for model_id in self._cache if self._is_local_model(model_id)]
404
- while len(local_ids) >= self._max_local_models:
405
- victim = min(local_ids, key=lambda model_id: self._last_used.get(model_id, 0))
406
- if victim == incoming_key:
407
- break
408
- print(f"🧹 Unloading local model to stay within memory policy: {victim}")
409
- self.unload_model(victim)
321
+ with self._lock:
410
322
  local_ids = [model_id for model_id in self._cache if self._is_local_model(model_id)]
323
+ while len(local_ids) >= self._max_local_models:
324
+ victim = min(local_ids, key=lambda model_id: self._last_used.get(model_id, 0))
325
+ if victim == incoming_key:
326
+ break
327
+ print(f"🧹 Unloading local model to stay within memory policy: {victim}")
328
+ self.unload_model(victim)
329
+ local_ids = [model_id for model_id in self._cache if self._is_local_model(model_id)]
411
330
 
412
331
  def _release_memory(self) -> None:
413
332
  gc.collect()
@@ -434,10 +353,11 @@ class LLMRouter:
434
353
  raise RuntimeError("MLX is not available in this process. Run on Apple Silicon with Metal access.")
435
354
 
436
355
  cache_key = f"{model_id}_{draft_model_id}" if draft_model_id else model_id
437
- if cache_key in self._cache:
438
- self._current = cache_key
439
- self._touch(cache_key)
440
- return f"Cached: {cache_key}"
356
+ with self._lock:
357
+ if cache_key in self._cache:
358
+ self._current = cache_key
359
+ self._touch(cache_key)
360
+ return f"Cached: {cache_key}"
441
361
 
442
362
  self._enforce_local_model_limit(cache_key)
443
363
  print(f"⏳ Loading local model stack: {cache_key}...")
@@ -479,9 +399,10 @@ class LLMRouter:
479
399
  try:
480
400
  # Use the dedicated single-thread executor to ensure MLX GPU streams match during inference
481
401
  model, tokenizer, draft_model, loader_kind = await loop.run_in_executor(executor, _load)
482
- self._cache[cache_key] = (model, tokenizer, draft_model, loader_kind)
483
- self._current = cache_key
484
- self._touch(cache_key)
402
+ with self._lock:
403
+ self._cache[cache_key] = (model, tokenizer, draft_model, loader_kind)
404
+ self._current = cache_key
405
+ self._touch(cache_key)
485
406
  print(f"✅ Fully Loaded: {cache_key} ({loader_kind})")
486
407
  return f"Success: {cache_key} ({loader_kind})"
487
408
  except Exception as e:
@@ -507,9 +428,10 @@ class LLMRouter:
507
428
 
508
429
  cache_owner = owner or "global"
509
430
  cache_key = f"{provider}:{model}::{cache_owner}"
510
- self._cache[cache_key] = CloudModel(provider=provider, model=model, client=AsyncOpenAI(**client_kwargs), cache_key=cache_key)
511
- self._current = cache_key
512
- self._touch(cache_key)
431
+ with self._lock:
432
+ self._cache[cache_key] = CloudModel(provider=provider, model=model, client=AsyncOpenAI(**client_kwargs), cache_key=cache_key)
433
+ self._current = cache_key
434
+ self._touch(cache_key)
513
435
  return f"Cloud provider ready: {cache_key}"
514
436
 
515
437
  def detected_cloud_models(self) -> List[Dict[str, str]]:
@@ -5,10 +5,11 @@ Returns dict of get_audit_log / append_audit_event for legacy namespace.
5
5
  from __future__ import annotations
6
6
 
7
7
  import json
8
- from datetime import datetime
9
8
  from pathlib import Path
10
9
  from typing import Any, Callable, Dict, List, Optional
11
10
 
11
+ from latticeai.core import timezones
12
+
12
13
 
13
14
  def build_audit_runtime(
14
15
  *,
@@ -17,18 +18,33 @@ def build_audit_runtime(
17
18
  redact_fn: Optional[Callable[[str], str]] = None,
18
19
  ) -> Dict[str, Any]:
19
20
  _audit_lock: Any = __import__("threading").Lock()
21
+ audit_jsonl = audit_file.with_suffix(audit_file.suffix + ".jsonl")
20
22
 
21
23
  def _read_audit() -> List[Dict[str, Any]]:
24
+ events: List[Dict[str, Any]] = []
22
25
  if not audit_file.exists():
23
- return []
24
- try:
25
- data = json.loads(audit_file.read_text(encoding="utf-8"))
26
- return data if isinstance(data, list) else []
27
- except Exception:
28
- return []
26
+ data = []
27
+ else:
28
+ try:
29
+ data = json.loads(audit_file.read_text(encoding="utf-8"))
30
+ except Exception:
31
+ data = []
32
+ if isinstance(data, list):
33
+ events.extend(item for item in data if isinstance(item, dict))
34
+ if audit_jsonl.exists():
35
+ try:
36
+ for line in audit_jsonl.read_text(encoding="utf-8").splitlines():
37
+ if not line.strip():
38
+ continue
39
+ item = json.loads(line)
40
+ if isinstance(item, dict):
41
+ events.append(item)
42
+ except Exception:
43
+ pass
44
+ return events[-5000:]
29
45
 
30
46
  def _append(event_type: str, **payload: Any) -> None:
31
- entry = {"event_type": event_type, "timestamp": datetime.now().isoformat()}
47
+ entry = {"event_type": event_type, "timestamp": timezones.now_iso()}
32
48
  if payload:
33
49
  entry.update(payload)
34
50
  if redact_fn:
@@ -39,15 +55,10 @@ def build_audit_runtime(
39
55
  except Exception:
40
56
  pass
41
57
  with _audit_lock:
42
- events = _read_audit()
43
- events.append(entry)
44
- # keep last N to bound size (legacy behavior)
45
- if len(events) > 5000:
46
- events = events[-5000:]
47
58
  audit_file.parent.mkdir(parents=True, exist_ok=True)
48
- tmp = audit_file.with_suffix(".tmp")
49
- tmp.write_text(json.dumps(events, ensure_ascii=False, indent=2), encoding="utf-8")
50
- tmp.replace(audit_file)
59
+ with audit_jsonl.open("a", encoding="utf-8") as fh:
60
+ fh.write(json.dumps(entry, ensure_ascii=False, default=str))
61
+ fh.write("\n")
51
62
 
52
63
  def get_audit_log() -> List[Dict[str, Any]]:
53
64
  return _read_audit()
@@ -40,12 +40,21 @@ def build_automation_runtime(
40
40
  review_sink=review_queue,
41
41
  tz_name=tz_name,
42
42
  )
43
+ def _memory_ingest(**kwargs):
44
+ try:
45
+ # Delegate to store's upsert (enriches memories + KG automatically via internal graph.ingest_event)
46
+ return store.upsert_memory(**kwargs)
47
+ except Exception:
48
+ return None
49
+
43
50
  agent_runtime = AgentRuntime(
44
51
  store=store,
45
52
  orchestrator_factory=platform.build_orchestrator,
46
53
  workspace_graph=workspace_graph,
47
54
  append_audit_event=append_audit_event,
48
55
  hooks=hooks,
56
+ memory_ingest=_memory_ingest,
57
+ review_sink=review_queue,
49
58
  )
50
59
  run_executor = RunExecutor(
51
60
  store=store,
@@ -24,7 +24,7 @@ def build_session_runtime(
24
24
 
25
25
  from latticeai.core.sessions import SessionStore
26
26
 
27
- session_store = SessionStore()
27
+ session_store = SessionStore(ttl_seconds=ttl_seconds)
28
28
 
29
29
  def create_session(email: str) -> str:
30
30
  return session_store.create(user_id_resolver(email) or email, email=email)