ltcai 4.2.0 → 4.3.1
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 -21
- package/bin/ltcai.js +6 -2
- package/docs/CHANGELOG.md +72 -0
- package/docs/V4_3_PORTABILITY_ARCHITECTURE.md +69 -0
- package/docs/V4_3_PRIVACY_AUDIT.md +60 -0
- package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +53 -0
- package/docs/V4_3_VALIDATION_REPORT.md +58 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +19 -25
- package/frontend/openapi.json +213 -1
- package/frontend/src/App.tsx +15 -1
- package/frontend/src/api/client.ts +26 -1
- package/frontend/src/api/openapi.ts +268 -0
- package/frontend/src/pages/Act.tsx +63 -2
- package/frontend/src/pages/Library.tsx +9 -3
- package/frontend/src/pages/System.tsx +58 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +360 -47
- package/lattice_brain/storage/sqlite.py +15 -2
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +11 -0
- package/latticeai/api/agents.py +3 -1
- package/latticeai/api/models.py +66 -18
- package/latticeai/api/portability.py +59 -2
- package/latticeai/app_factory.py +9 -0
- package/latticeai/brain/projection.py +12 -2
- package/latticeai/brain/retrieval.py +10 -0
- package/latticeai/brain/store.py +6 -1
- package/latticeai/core/config.py +4 -2
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/product_hardening.py +218 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/agent_runtime.py +52 -12
- package/latticeai/services/kg_portability.py +147 -4
- package/latticeai/services/model_runtime.py +83 -2
- package/ltcai_cli.py +16 -4
- package/package.json +5 -4
- package/requirements.txt +17 -0
- package/scripts/clean_release_artifacts.mjs +27 -0
- package/scripts/lint_frontend.mjs +5 -0
- package/scripts/validate_release_artifacts.py +10 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/main.rs +356 -24
- package/src-tauri/tauri.conf.json +20 -1
- package/static/app/asset-manifest.json +5 -5
- package/static/app/assets/{index-C_HAkbAg.js → index-BhPuj8rT.js} +45 -45
- package/static/app/assets/index-BhPuj8rT.js.map +1 -0
- package/static/app/assets/{index-CDjiH_se.css → index-yZswHE3d.css} +1 -1
- package/static/app/index.html +2 -2
- package/static/app/assets/index-C_HAkbAg.js.map +0 -1
|
@@ -50,6 +50,10 @@ def _now() -> str:
|
|
|
50
50
|
return datetime.now().isoformat(timespec="seconds")
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
class AgentRuntimeUnavailable(RuntimeError):
|
|
54
|
+
"""Raised when a product run would otherwise persist simulation output."""
|
|
55
|
+
|
|
56
|
+
|
|
53
57
|
class AgentRuntime:
|
|
54
58
|
def __init__(
|
|
55
59
|
self,
|
|
@@ -60,6 +64,7 @@ class AgentRuntime:
|
|
|
60
64
|
append_audit_event: Callable[..., None],
|
|
61
65
|
max_retries_cap: int = 5,
|
|
62
66
|
hooks: Any = None,
|
|
67
|
+
allow_simulation_runs: bool = False,
|
|
63
68
|
):
|
|
64
69
|
self._store = store
|
|
65
70
|
self._orchestrator_factory = orchestrator_factory
|
|
@@ -69,6 +74,7 @@ class AgentRuntime:
|
|
|
69
74
|
# Lifecycle hooks registry (optional). When present, ``start`` fires the
|
|
70
75
|
# ``pre_run`` / ``post_run`` hooks; a blocking ``pre_run`` aborts the run.
|
|
71
76
|
self._hooks = hooks
|
|
77
|
+
self._allow_simulation_runs = bool(allow_simulation_runs)
|
|
72
78
|
self._run_executor: Any = None
|
|
73
79
|
|
|
74
80
|
def attach_executor(self, executor: Any) -> None:
|
|
@@ -85,6 +91,7 @@ class AgentRuntime:
|
|
|
85
91
|
"default_pipeline": list(CORE_PIPELINE),
|
|
86
92
|
"max_retries_cap": self._max_retries_cap,
|
|
87
93
|
"execution_mode": self._execution_mode(),
|
|
94
|
+
"simulation_runs_allowed": self._allow_simulation_runs,
|
|
88
95
|
"cancellation": (
|
|
89
96
|
"cooperative; running synchronous model/tool calls finish their current step before a cancelled status is persisted"
|
|
90
97
|
if self._run_executor is not None else
|
|
@@ -107,6 +114,7 @@ class AgentRuntime:
|
|
|
107
114
|
def health(self) -> Dict[str, Any]:
|
|
108
115
|
checks: Dict[str, Any] = {}
|
|
109
116
|
ok = True
|
|
117
|
+
ready = True
|
|
110
118
|
try:
|
|
111
119
|
self._store.list_agents(workspace_id=None)
|
|
112
120
|
checks["run_store"] = {"status": "ok"}
|
|
@@ -114,12 +122,42 @@ class AgentRuntime:
|
|
|
114
122
|
ok = False
|
|
115
123
|
checks["run_store"] = {"status": "error", "detail": str(exc)}
|
|
116
124
|
try:
|
|
117
|
-
self._orchestrator_factory(None, None)
|
|
118
|
-
|
|
125
|
+
orchestrator = self._orchestrator_factory(None, None)
|
|
126
|
+
mode = getattr(orchestrator, "mode", "simulation")
|
|
127
|
+
if mode == "simulation":
|
|
128
|
+
if self._allow_simulation_runs:
|
|
129
|
+
checks["orchestrator"] = {
|
|
130
|
+
"status": "ok",
|
|
131
|
+
"mode": mode,
|
|
132
|
+
"detail": "Simulation runs are explicitly enabled for this non-product runtime.",
|
|
133
|
+
}
|
|
134
|
+
else:
|
|
135
|
+
ready = False
|
|
136
|
+
checks["orchestrator"] = {
|
|
137
|
+
"status": "unavailable",
|
|
138
|
+
"mode": mode,
|
|
139
|
+
"detail": "No LLM-backed model is loaded; product execution API refuses simulation runs.",
|
|
140
|
+
}
|
|
141
|
+
else:
|
|
142
|
+
checks["orchestrator"] = {"status": "ok", "mode": mode}
|
|
119
143
|
except Exception as exc: # pragma: no cover - defensive
|
|
120
144
|
ok = False
|
|
121
145
|
checks["orchestrator"] = {"status": "error", "detail": str(exc)}
|
|
122
|
-
return {
|
|
146
|
+
return {
|
|
147
|
+
"status": "ok" if ok and ready else "unavailable" if ok else "degraded",
|
|
148
|
+
"ready": bool(ok and ready),
|
|
149
|
+
"checks": checks,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
def _live_orchestrator(self, user_email: Optional[str], scope: Optional[str]) -> Any:
|
|
153
|
+
orchestrator = self._orchestrator_factory(user_email or None, scope)
|
|
154
|
+
mode = getattr(orchestrator, "mode", "simulation")
|
|
155
|
+
if mode == "simulation" and not self._allow_simulation_runs:
|
|
156
|
+
raise AgentRuntimeUnavailable(
|
|
157
|
+
"Agent execution is unavailable because no LLM-backed model is loaded. "
|
|
158
|
+
"Simulation mode is disabled in the product execution API so it cannot be recorded as real success."
|
|
159
|
+
)
|
|
160
|
+
return orchestrator
|
|
123
161
|
|
|
124
162
|
# ── roster + status ───────────────────────────────────────────────────
|
|
125
163
|
def _roster(self, runs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
@@ -163,16 +201,21 @@ class AgentRuntime:
|
|
|
163
201
|
listing = {"agents": [], "runs": [], "error": str(exc)}
|
|
164
202
|
runs = list(listing.get("runs") or [])
|
|
165
203
|
active = sum(1 for r in runs if str(r.get("status")) in _ACTIVE_STATUSES)
|
|
204
|
+
health = self.health()
|
|
205
|
+
orchestrator_status = (health.get("checks") or {}).get("orchestrator") or {}
|
|
206
|
+
ready = bool(health.get("ready"))
|
|
166
207
|
return {
|
|
167
208
|
"runtime": {
|
|
168
|
-
"ready":
|
|
209
|
+
"ready": ready,
|
|
169
210
|
"version": MULTI_AGENT_VERSION,
|
|
170
211
|
"execution_mode": self._execution_mode(),
|
|
212
|
+
"mode": orchestrator_status.get("mode", "unknown"),
|
|
213
|
+
"unavailable_reason": None if ready else orchestrator_status.get("detail"),
|
|
171
214
|
"default_pipeline": list(CORE_PIPELINE),
|
|
172
215
|
"total_runs": len(runs),
|
|
173
216
|
"active_runs": active,
|
|
174
217
|
},
|
|
175
|
-
"health":
|
|
218
|
+
"health": health,
|
|
176
219
|
"roles": self.roles(),
|
|
177
220
|
"agents": self._roster(runs),
|
|
178
221
|
"runs": runs[:25],
|
|
@@ -288,11 +331,8 @@ class AgentRuntime:
|
|
|
288
331
|
user_email=user_email,
|
|
289
332
|
scope=scope,
|
|
290
333
|
)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
mode = getattr(orchestrator, "mode", "simulation")
|
|
294
|
-
except Exception:
|
|
295
|
-
mode = "simulation"
|
|
334
|
+
orchestrator = self._live_orchestrator(user_email, scope)
|
|
335
|
+
mode = getattr(orchestrator, "mode", "llm")
|
|
296
336
|
run = self._store.record_agent_run(
|
|
297
337
|
agent_id=ROLE_AGENT_IDS.get("executor", "agent:executor"),
|
|
298
338
|
status="queued",
|
|
@@ -349,7 +389,7 @@ class AgentRuntime:
|
|
|
349
389
|
started_at=run.get("started_at") or _now(),
|
|
350
390
|
)
|
|
351
391
|
try:
|
|
352
|
-
orchestrator = self.
|
|
392
|
+
orchestrator = self._live_orchestrator(user_email, scope)
|
|
353
393
|
result = orchestrator.run(
|
|
354
394
|
goal,
|
|
355
395
|
user_email=user_email or None,
|
|
@@ -452,7 +492,7 @@ class AgentRuntime:
|
|
|
452
492
|
)
|
|
453
493
|
raise PermissionError(pre_dispatch.get("block_reason") or "Agent run blocked by a pre_run hook.")
|
|
454
494
|
|
|
455
|
-
orchestrator = self.
|
|
495
|
+
orchestrator = self._live_orchestrator(user_email, scope)
|
|
456
496
|
result = orchestrator.run(
|
|
457
497
|
goal,
|
|
458
498
|
user_email=user_email or None,
|
|
@@ -19,7 +19,7 @@ import shutil
|
|
|
19
19
|
import tempfile
|
|
20
20
|
import zipfile
|
|
21
21
|
from datetime import datetime, timezone
|
|
22
|
-
from pathlib import Path
|
|
22
|
+
from pathlib import Path, PurePosixPath
|
|
23
23
|
from typing import Any, Dict, Optional
|
|
24
24
|
|
|
25
25
|
from lattice_brain.archive import BrainArchivePaths, EncryptedBrainArchive
|
|
@@ -50,6 +50,13 @@ def _sha256_file(path: Path) -> str:
|
|
|
50
50
|
return h.hexdigest()
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def _safe_zip_names(names) -> None:
|
|
54
|
+
for name in names:
|
|
55
|
+
path = PurePosixPath(name)
|
|
56
|
+
if path.is_absolute() or ".." in path.parts:
|
|
57
|
+
raise ValueError(f"Backup archive contains unsafe path: {name}")
|
|
58
|
+
|
|
59
|
+
|
|
53
60
|
class KGPortabilityService:
|
|
54
61
|
def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True, device_identity: Any = None) -> None:
|
|
55
62
|
self._kg = knowledge_graph
|
|
@@ -156,13 +163,23 @@ class KGPortabilityService:
|
|
|
156
163
|
zf.write(f, f"blobs/{f.relative_to(blob_dir)}")
|
|
157
164
|
return {"path": str(dest), "bytes": dest.stat().st_size, "manifest": manifest}
|
|
158
165
|
|
|
159
|
-
def restore(
|
|
166
|
+
def restore(
|
|
167
|
+
self,
|
|
168
|
+
archive_path,
|
|
169
|
+
*,
|
|
170
|
+
verify: bool = True,
|
|
171
|
+
dry_run: bool = False,
|
|
172
|
+
confirm: bool = False,
|
|
173
|
+
) -> Dict[str, Any]:
|
|
160
174
|
self._require()
|
|
161
175
|
archive = Path(archive_path)
|
|
162
176
|
if not archive.exists():
|
|
163
177
|
raise FileNotFoundError(f"Backup archive not found: {archive}")
|
|
178
|
+
if not dry_run and not confirm:
|
|
179
|
+
raise ValueError("Explicit confirmation is required before restoring a Knowledge Graph backup.")
|
|
164
180
|
with zipfile.ZipFile(archive) as zf:
|
|
165
181
|
names = zf.namelist()
|
|
182
|
+
_safe_zip_names(names)
|
|
166
183
|
if "knowledge_graph.sqlite" not in names:
|
|
167
184
|
raise ValueError("Archive is missing knowledge_graph.sqlite.")
|
|
168
185
|
manifest = json.loads(zf.read("manifest.json")) if "manifest.json" in names else {}
|
|
@@ -173,6 +190,18 @@ class KGPortabilityService:
|
|
|
173
190
|
if verify and manifest.get("db_sha256"):
|
|
174
191
|
if _sha256_file(db_src) != manifest["db_sha256"]:
|
|
175
192
|
raise ValueError("Backup integrity check failed (db sha256 mismatch).")
|
|
193
|
+
if dry_run:
|
|
194
|
+
return {
|
|
195
|
+
"restored": False,
|
|
196
|
+
"dry_run": True,
|
|
197
|
+
"verified": True,
|
|
198
|
+
"manifest": manifest,
|
|
199
|
+
"planned": {
|
|
200
|
+
"database": str(self._kg.db_path),
|
|
201
|
+
"blobs": str(self._kg.blob_dir),
|
|
202
|
+
"archive": str(archive),
|
|
203
|
+
},
|
|
204
|
+
}
|
|
176
205
|
db_dest = Path(self._kg.db_path)
|
|
177
206
|
blob_dest = Path(self._kg.blob_dir)
|
|
178
207
|
db_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -196,25 +225,82 @@ class KGPortabilityService:
|
|
|
196
225
|
"nodes": sum(stats.get("nodes", {}).values()),
|
|
197
226
|
}
|
|
198
227
|
|
|
228
|
+
def verify_backup(self, archive_path) -> Dict[str, Any]:
|
|
229
|
+
archive = Path(archive_path)
|
|
230
|
+
if not archive.exists():
|
|
231
|
+
return {"ok": False, "path": str(archive), "errors": [f"Backup archive not found: {archive}"]}
|
|
232
|
+
try:
|
|
233
|
+
with zipfile.ZipFile(archive) as zf:
|
|
234
|
+
names = zf.namelist()
|
|
235
|
+
_safe_zip_names(names)
|
|
236
|
+
if "knowledge_graph.sqlite" not in names:
|
|
237
|
+
raise ValueError("Archive is missing knowledge_graph.sqlite.")
|
|
238
|
+
manifest = json.loads(zf.read("manifest.json")) if "manifest.json" in names else {}
|
|
239
|
+
with tempfile.TemporaryDirectory() as tmp_s:
|
|
240
|
+
tmp = Path(tmp_s)
|
|
241
|
+
zf.extract("knowledge_graph.sqlite", tmp)
|
|
242
|
+
db_src = tmp / "knowledge_graph.sqlite"
|
|
243
|
+
if manifest.get("db_sha256") and _sha256_file(db_src) != manifest["db_sha256"]:
|
|
244
|
+
raise ValueError("Backup integrity check failed (db sha256 mismatch).")
|
|
245
|
+
return {"ok": True, "path": str(archive), "manifest": manifest, "errors": []}
|
|
246
|
+
except (ValueError, zipfile.BadZipFile, OSError, json.JSONDecodeError) as exc:
|
|
247
|
+
return {"ok": False, "path": str(archive), "errors": [str(exc)]}
|
|
248
|
+
|
|
199
249
|
# ── encrypted .latticebrain archive ───────────────────────────────────
|
|
200
250
|
def encrypted_archive(self, dest_path=None, *, passphrase: str) -> Dict[str, Any]:
|
|
201
251
|
self._require()
|
|
202
252
|
self._exports_dir.mkdir(parents=True, exist_ok=True)
|
|
203
253
|
dest = Path(dest_path) if dest_path else self._exports_dir / f"brain-{_stamp()}.latticebrain"
|
|
254
|
+
metadata = {
|
|
255
|
+
"storage": self.storage_status().get("active", {}),
|
|
256
|
+
"snapshot": self.snapshot_metadata(),
|
|
257
|
+
"device_identity": self._identity.describe() if self._identity is not None else {},
|
|
258
|
+
"provenance": {"exported_at": _now_iso(), "source": "kg-portability"},
|
|
259
|
+
}
|
|
204
260
|
archive = EncryptedBrainArchive(
|
|
205
261
|
BrainArchivePaths(
|
|
206
262
|
db_path=Path(self._kg.db_path),
|
|
207
263
|
blob_dir=Path(self._kg.blob_dir),
|
|
264
|
+
data_dir=self._data_dir,
|
|
265
|
+
metadata=metadata,
|
|
208
266
|
)
|
|
209
267
|
)
|
|
210
268
|
return archive.create(dest, passphrase=passphrase)
|
|
211
269
|
|
|
212
|
-
def
|
|
270
|
+
def inspect_encrypted_archive(self, archive_path, *, passphrase: Optional[str] = None) -> Dict[str, Any]:
|
|
271
|
+
archive = EncryptedBrainArchive(
|
|
272
|
+
BrainArchivePaths(
|
|
273
|
+
db_path=Path(self._kg.db_path),
|
|
274
|
+
blob_dir=Path(self._kg.blob_dir),
|
|
275
|
+
data_dir=self._data_dir,
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
return archive.inspect(Path(archive_path), passphrase=passphrase)
|
|
279
|
+
|
|
280
|
+
def verify_encrypted_archive(self, archive_path, *, passphrase: str) -> Dict[str, Any]:
|
|
281
|
+
archive = EncryptedBrainArchive(
|
|
282
|
+
BrainArchivePaths(
|
|
283
|
+
db_path=Path(self._kg.db_path),
|
|
284
|
+
blob_dir=Path(self._kg.blob_dir),
|
|
285
|
+
data_dir=self._data_dir,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
return archive.verify(Path(archive_path), passphrase=passphrase)
|
|
289
|
+
|
|
290
|
+
def restore_encrypted_archive(
|
|
291
|
+
self,
|
|
292
|
+
archive_path,
|
|
293
|
+
*,
|
|
294
|
+
passphrase: str,
|
|
295
|
+
dry_run: bool = False,
|
|
296
|
+
confirm: bool = False,
|
|
297
|
+
) -> Dict[str, Any]:
|
|
213
298
|
self._require()
|
|
214
299
|
archive = EncryptedBrainArchive(
|
|
215
300
|
BrainArchivePaths(
|
|
216
301
|
db_path=Path(self._kg.db_path),
|
|
217
302
|
blob_dir=Path(self._kg.blob_dir),
|
|
303
|
+
data_dir=self._data_dir,
|
|
218
304
|
)
|
|
219
305
|
)
|
|
220
306
|
return archive.restore(
|
|
@@ -223,9 +309,29 @@ class KGPortabilityService:
|
|
|
223
309
|
target=BrainArchivePaths(
|
|
224
310
|
db_path=Path(self._kg.db_path),
|
|
225
311
|
blob_dir=Path(self._kg.blob_dir),
|
|
312
|
+
data_dir=self._data_dir,
|
|
226
313
|
),
|
|
314
|
+
dry_run=dry_run,
|
|
315
|
+
confirm=confirm,
|
|
227
316
|
)
|
|
228
317
|
|
|
318
|
+
def import_encrypted_archive(
|
|
319
|
+
self,
|
|
320
|
+
archive_path,
|
|
321
|
+
*,
|
|
322
|
+
passphrase: str,
|
|
323
|
+
dry_run: bool = False,
|
|
324
|
+
confirm: bool = False,
|
|
325
|
+
) -> Dict[str, Any]:
|
|
326
|
+
result = self.restore_encrypted_archive(
|
|
327
|
+
archive_path,
|
|
328
|
+
passphrase=passphrase,
|
|
329
|
+
dry_run=dry_run,
|
|
330
|
+
confirm=confirm,
|
|
331
|
+
)
|
|
332
|
+
result["operation"] = "import"
|
|
333
|
+
return result
|
|
334
|
+
|
|
229
335
|
# ── status surface ───────────────────────────────────────────────────────
|
|
230
336
|
def snapshot_metadata(self) -> Dict[str, Any]:
|
|
231
337
|
if not self.available():
|
|
@@ -253,6 +359,28 @@ class KGPortabilityService:
|
|
|
253
359
|
else {"engine": "sqlite", "available": True}
|
|
254
360
|
),
|
|
255
361
|
"postgres": PostgresEngine("", schema="lattice_brain").capabilities().as_dict(),
|
|
362
|
+
"backup_health": self.backup_health(),
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
def backup_health(self) -> Dict[str, Any]:
|
|
366
|
+
self._exports_dir.mkdir(parents=True, exist_ok=True)
|
|
367
|
+
backups = sorted(
|
|
368
|
+
[
|
|
369
|
+
p for p in self._exports_dir.glob("*")
|
|
370
|
+
if p.is_file() and (p.suffix == ".zip" or p.suffix == ".latticebrain")
|
|
371
|
+
],
|
|
372
|
+
key=lambda p: p.stat().st_mtime,
|
|
373
|
+
reverse=True,
|
|
374
|
+
)
|
|
375
|
+
latest = backups[0] if backups else None
|
|
376
|
+
return {
|
|
377
|
+
"available": True,
|
|
378
|
+
"directory": str(self._exports_dir),
|
|
379
|
+
"count": len(backups),
|
|
380
|
+
"latest": str(latest) if latest else None,
|
|
381
|
+
"latest_bytes": latest.stat().st_size if latest else 0,
|
|
382
|
+
"encrypted_archives": sum(1 for p in backups if p.suffix == ".latticebrain"),
|
|
383
|
+
"zip_backups": sum(1 for p in backups if p.suffix == ".zip"),
|
|
256
384
|
}
|
|
257
385
|
|
|
258
386
|
def postgres_docker_setup(
|
|
@@ -279,7 +407,22 @@ class KGPortabilityService:
|
|
|
279
407
|
Path(self._kg.db_path),
|
|
280
408
|
PostgresEngine(dsn, schema=schema),
|
|
281
409
|
)
|
|
282
|
-
|
|
410
|
+
if dry_run:
|
|
411
|
+
return migrator.migrate(dry_run=True)
|
|
412
|
+
backup = self.backup()
|
|
413
|
+
verification = self.verify_backup(backup["path"])
|
|
414
|
+
if not verification.get("ok"):
|
|
415
|
+
raise RuntimeError(
|
|
416
|
+
"Pre-migration backup verification failed; Postgres migration was not started: "
|
|
417
|
+
+ "; ".join(verification.get("errors") or [])
|
|
418
|
+
)
|
|
419
|
+
result = migrator.migrate(dry_run=False)
|
|
420
|
+
result["pre_migration_backup"] = {
|
|
421
|
+
"path": backup["path"],
|
|
422
|
+
"verified": True,
|
|
423
|
+
"manifest": backup.get("manifest"),
|
|
424
|
+
}
|
|
425
|
+
return result
|
|
283
426
|
|
|
284
427
|
def recent_ingestions(self, *, limit: int = 50, source_type: Optional[str] = None) -> Dict[str, Any]:
|
|
285
428
|
"""Recent provenance records (newest first) for the ingestion-sources UI."""
|
|
@@ -66,6 +66,51 @@ IS_PUBLIC_MODE = False
|
|
|
66
66
|
keyring = None
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def _env_bool(key: str, default: bool = False) -> bool:
|
|
70
|
+
raw = os.getenv(key)
|
|
71
|
+
if raw is None:
|
|
72
|
+
return default
|
|
73
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _download_allowed(allow_download: bool = False) -> bool:
|
|
77
|
+
return bool(allow_download) or _env_bool("LATTICEAI_ALLOW_MODEL_DOWNLOADS", default=False) or bool(AUTOLOAD_MODELS)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _download_block(provider: str, model_name: str) -> None:
|
|
81
|
+
raise HTTPException(
|
|
82
|
+
status_code=409,
|
|
83
|
+
detail={
|
|
84
|
+
"status": "unavailable",
|
|
85
|
+
"capability": "model_download",
|
|
86
|
+
"provider": provider,
|
|
87
|
+
"model": model_name,
|
|
88
|
+
"reason": (
|
|
89
|
+
"Model files are not present locally. Lattice AI does not start "
|
|
90
|
+
"outbound model downloads by default, and token/model presence "
|
|
91
|
+
"alone never authorizes network activity."
|
|
92
|
+
),
|
|
93
|
+
"action": "Use the explicit pull/prepare flow with download consent, or set LATTICEAI_ALLOW_MODEL_DOWNLOADS=true.",
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _engine_install_block(engine: str) -> None:
|
|
99
|
+
raise HTTPException(
|
|
100
|
+
status_code=409,
|
|
101
|
+
detail={
|
|
102
|
+
"status": "unavailable",
|
|
103
|
+
"capability": "engine_install",
|
|
104
|
+
"engine": engine,
|
|
105
|
+
"reason": (
|
|
106
|
+
"The requested local runtime is not installed. Lattice AI does not "
|
|
107
|
+
"run package-manager or installer commands from Model Load by default."
|
|
108
|
+
),
|
|
109
|
+
"action": "Install the runtime explicitly from Library/System setup, or enable explicit download/install consent for this request.",
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
69
114
|
def _missing_current_user(_request: Request) -> Optional[str]:
|
|
70
115
|
return None
|
|
71
116
|
|
|
@@ -1307,6 +1352,7 @@ async def prepare_and_load_model(
|
|
|
1307
1352
|
user_email: Optional[str] = None,
|
|
1308
1353
|
adapter_path: Optional[str] = None,
|
|
1309
1354
|
draft_model_id: Optional[str] = None,
|
|
1355
|
+
allow_download: bool = False,
|
|
1310
1356
|
) -> Dict[str, object]:
|
|
1311
1357
|
model_id = normalize_local_model_request(model_id, engine)
|
|
1312
1358
|
if not model_id:
|
|
@@ -1329,11 +1375,15 @@ async def prepare_and_load_model(
|
|
|
1329
1375
|
download_result: Optional[Dict[str, object]] = None
|
|
1330
1376
|
|
|
1331
1377
|
if parsed_provider in local_engines:
|
|
1378
|
+
if not engine_installed(parsed_provider) and not _download_allowed(allow_download):
|
|
1379
|
+
_engine_install_block(parsed_provider)
|
|
1332
1380
|
install_result = ensure_engine_ready(parsed_provider)
|
|
1333
1381
|
|
|
1334
1382
|
if parsed_provider == "local_mlx":
|
|
1335
1383
|
explicit_path = Path(parsed_model).expanduser()
|
|
1336
1384
|
if not explicit_path.exists() and not hf_model_ready(parsed_model, "local_mlx"):
|
|
1385
|
+
if not _download_allowed(allow_download):
|
|
1386
|
+
_download_block(parsed_provider, parsed_model)
|
|
1337
1387
|
download_result = download_hf_model(parsed_model, "local_mlx")
|
|
1338
1388
|
elif parsed_provider == "ollama":
|
|
1339
1389
|
ensure_ollama_server()
|
|
@@ -1341,6 +1391,8 @@ async def prepare_and_load_model(
|
|
|
1341
1391
|
if not ollama:
|
|
1342
1392
|
raise HTTPException(status_code=400, detail="Ollama가 설치되지 않았습니다.")
|
|
1343
1393
|
if parsed_model not in get_ollama_pulled_models():
|
|
1394
|
+
if not _download_allowed(allow_download):
|
|
1395
|
+
_download_block(parsed_provider, parsed_model)
|
|
1344
1396
|
completed = subprocess.run(
|
|
1345
1397
|
[ollama, "pull", parsed_model],
|
|
1346
1398
|
capture_output=True,
|
|
@@ -1352,12 +1404,23 @@ async def prepare_and_load_model(
|
|
|
1352
1404
|
raise HTTPException(status_code=500, detail=completed.stderr[-2000:] or "Ollama 모델 다운로드 실패")
|
|
1353
1405
|
download_result = {"provider": "ollama", "model": parsed_model, "returncode": completed.returncode}
|
|
1354
1406
|
elif parsed_provider == "vllm":
|
|
1407
|
+
if not hf_model_ready(parsed_model, "vllm") and not _download_allowed(allow_download):
|
|
1408
|
+
_download_block(parsed_provider, parsed_model)
|
|
1355
1409
|
ensure_vllm_server(parsed_model)
|
|
1356
1410
|
download_result = {"provider": "vllm", "model": parsed_model, "server_ready": True}
|
|
1357
1411
|
elif parsed_provider == "llamacpp":
|
|
1412
|
+
if not hf_model_ready(parsed_model, "llamacpp") and not _download_allowed(allow_download):
|
|
1413
|
+
_download_block(parsed_provider, parsed_model)
|
|
1358
1414
|
ensure_llamacpp_server(parsed_model)
|
|
1359
1415
|
download_result = {"provider": "llamacpp", "model": parsed_model, "server_ready": True}
|
|
1360
1416
|
elif parsed_provider == "lmstudio":
|
|
1417
|
+
downloaded = {
|
|
1418
|
+
str(item.get("key") or "").strip()
|
|
1419
|
+
for item in get_lmstudio_models()
|
|
1420
|
+
if isinstance(item, dict)
|
|
1421
|
+
}
|
|
1422
|
+
if parsed_model not in downloaded and not _download_allowed(allow_download):
|
|
1423
|
+
_download_block(parsed_provider, parsed_model)
|
|
1361
1424
|
ensured = ensure_lmstudio_model(parsed_model)
|
|
1362
1425
|
resolved_model = str(
|
|
1363
1426
|
ensured.get("instance_id")
|
|
@@ -1399,7 +1462,7 @@ async def prepare_and_load_model(
|
|
|
1399
1462
|
"installed_now": bool(install_result.get("installed_now")),
|
|
1400
1463
|
"download": download_result,
|
|
1401
1464
|
"resolution": resolution.to_dict(),
|
|
1402
|
-
"downloaded":
|
|
1465
|
+
"downloaded": bool(download_result and not (isinstance(download_result, dict) and download_result.get("cached"))),
|
|
1403
1466
|
"loaded": True,
|
|
1404
1467
|
"ready_to_chat": ready_to_chat,
|
|
1405
1468
|
"compatibility_status": compat_status,
|
|
@@ -1416,6 +1479,7 @@ async def prepare_and_load_model_stream(
|
|
|
1416
1479
|
request: Request,
|
|
1417
1480
|
engine: Optional[str] = None,
|
|
1418
1481
|
user_email: Optional[str] = None,
|
|
1482
|
+
allow_download: bool = False,
|
|
1419
1483
|
) -> AsyncIterator[str]:
|
|
1420
1484
|
model_id = normalize_local_model_request(model_id, engine)
|
|
1421
1485
|
if not model_id:
|
|
@@ -1446,6 +1510,8 @@ async def prepare_and_load_model_stream(
|
|
|
1446
1510
|
percent=2,
|
|
1447
1511
|
indeterminate=True,
|
|
1448
1512
|
))
|
|
1513
|
+
if not engine_installed(parsed_provider) and not _download_allowed(allow_download):
|
|
1514
|
+
_engine_install_block(parsed_provider)
|
|
1449
1515
|
install_result = ensure_engine_ready(parsed_provider)
|
|
1450
1516
|
emit_progress(model_download_progress_payload(
|
|
1451
1517
|
"engine",
|
|
@@ -1466,6 +1532,8 @@ async def prepare_and_load_model_stream(
|
|
|
1466
1532
|
eta_seconds=0,
|
|
1467
1533
|
))
|
|
1468
1534
|
elif not hf_model_ready(parsed_model, "local_mlx"):
|
|
1535
|
+
if not _download_allowed(allow_download):
|
|
1536
|
+
_download_block(parsed_provider, parsed_model)
|
|
1469
1537
|
download_result = download_hf_model(parsed_model, "local_mlx", progress_emit=emit_progress)
|
|
1470
1538
|
else:
|
|
1471
1539
|
download_result = {"model": parsed_model, "path": str(hf_model_dir(parsed_model)), "cached": True}
|
|
@@ -1484,6 +1552,8 @@ async def prepare_and_load_model_stream(
|
|
|
1484
1552
|
))
|
|
1485
1553
|
ensure_ollama_server()
|
|
1486
1554
|
if parsed_model not in get_ollama_pulled_models():
|
|
1555
|
+
if not _download_allowed(allow_download):
|
|
1556
|
+
_download_block(parsed_provider, parsed_model)
|
|
1487
1557
|
download_result = pull_ollama_model_with_progress(parsed_model, progress_emit=emit_progress)
|
|
1488
1558
|
else:
|
|
1489
1559
|
download_result = {"provider": "ollama", "model": parsed_model, "cached": True}
|
|
@@ -1496,6 +1566,8 @@ async def prepare_and_load_model_stream(
|
|
|
1496
1566
|
))
|
|
1497
1567
|
elif parsed_provider == "vllm":
|
|
1498
1568
|
if not hf_model_ready(parsed_model, "vllm"):
|
|
1569
|
+
if not _download_allowed(allow_download):
|
|
1570
|
+
_download_block(parsed_provider, parsed_model)
|
|
1499
1571
|
download_result = download_hf_model(parsed_model, "vllm", progress_emit=emit_progress)
|
|
1500
1572
|
else:
|
|
1501
1573
|
download_result = {"provider": "vllm", "model": parsed_model, "cached": True}
|
|
@@ -1516,6 +1588,8 @@ async def prepare_and_load_model_stream(
|
|
|
1516
1588
|
download_result = {**(download_result or {}), "provider": "vllm", "model": parsed_model, "server_ready": True}
|
|
1517
1589
|
elif parsed_provider == "llamacpp":
|
|
1518
1590
|
if not hf_model_ready(parsed_model, "llamacpp"):
|
|
1591
|
+
if not _download_allowed(allow_download):
|
|
1592
|
+
_download_block(parsed_provider, parsed_model)
|
|
1519
1593
|
download_result = download_hf_model(parsed_model, "llamacpp", progress_emit=emit_progress)
|
|
1520
1594
|
else:
|
|
1521
1595
|
download_result = {"provider": "llamacpp", "model": parsed_model, "cached": True}
|
|
@@ -1535,6 +1609,13 @@ async def prepare_and_load_model_stream(
|
|
|
1535
1609
|
ensure_llamacpp_server(parsed_model)
|
|
1536
1610
|
download_result = {**(download_result or {}), "provider": "llamacpp", "model": parsed_model, "server_ready": True}
|
|
1537
1611
|
elif parsed_provider == "lmstudio":
|
|
1612
|
+
downloaded = {
|
|
1613
|
+
str(item.get("key") or "").strip()
|
|
1614
|
+
for item in get_lmstudio_models()
|
|
1615
|
+
if isinstance(item, dict)
|
|
1616
|
+
}
|
|
1617
|
+
if parsed_model not in downloaded and not _download_allowed(allow_download):
|
|
1618
|
+
_download_block(parsed_provider, parsed_model)
|
|
1538
1619
|
emit_progress(model_download_progress_payload(
|
|
1539
1620
|
"download",
|
|
1540
1621
|
"LM Studio 모델을 확인하는 중입니다.",
|
|
@@ -1643,7 +1724,7 @@ async def prepare_and_load_model_stream(
|
|
|
1643
1724
|
"installed_now": bool(isinstance(install_result, dict) and install_result.get("installed_now")),
|
|
1644
1725
|
"download": download_result,
|
|
1645
1726
|
"resolution": resolution_stream.to_dict(),
|
|
1646
|
-
"downloaded":
|
|
1727
|
+
"downloaded": bool(download_result and not (isinstance(download_result, dict) and download_result.get("cached"))),
|
|
1647
1728
|
"loaded": True,
|
|
1648
1729
|
"ready_to_chat": ready_to_chat,
|
|
1649
1730
|
"compatibility_status": compat_status,
|
package/ltcai_cli.py
CHANGED
|
@@ -246,9 +246,15 @@ def main() -> None:
|
|
|
246
246
|
|
|
247
247
|
os.chdir(app_dir)
|
|
248
248
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
249
|
+
if not args.tunnel and os.getenv("LATTICEAI_TUNNEL", "").lower() in (
|
|
250
|
+
"1",
|
|
251
|
+
"true",
|
|
252
|
+
"yes",
|
|
253
|
+
):
|
|
254
|
+
print(
|
|
255
|
+
" LATTICEAI_TUNNEL is ignored during default local startup; "
|
|
256
|
+
"restart with --tunnel to expose this server."
|
|
257
|
+
)
|
|
252
258
|
|
|
253
259
|
# --tunnel forces 0.0.0.0 so cloudflared can reach the server
|
|
254
260
|
if args.tunnel and args.host == "127.0.0.1":
|
|
@@ -257,6 +263,11 @@ def main() -> None:
|
|
|
257
263
|
os.environ.setdefault("LATTICEAI_CORS_ALLOW_NETWORK", "true")
|
|
258
264
|
os.environ.setdefault("LATTICEAI_REQUIRE_AUTH", "true")
|
|
259
265
|
|
|
266
|
+
# Keep the app config in sync with CLI flags. ``Config.from_env`` is the
|
|
267
|
+
# source of truth for /mode, /health.features, SSO defaults, and routers.
|
|
268
|
+
os.environ["LATTICEAI_HOST"] = str(args.host)
|
|
269
|
+
os.environ["LATTICEAI_PORT"] = str(args.port)
|
|
270
|
+
|
|
260
271
|
tunnel_url: str | None = None
|
|
261
272
|
if args.tunnel:
|
|
262
273
|
print()
|
|
@@ -269,9 +280,10 @@ def main() -> None:
|
|
|
269
280
|
|
|
270
281
|
# Telegram startup notification (local start, tunnel handled separately inside _start_tunnel)
|
|
271
282
|
if not args.tunnel:
|
|
283
|
+
_tg_enabled = os.getenv("LATTICEAI_ENABLE_TELEGRAM", "").strip().lower() in ("1", "true", "yes", "on")
|
|
272
284
|
_tg_token = os.getenv("LATTICEAI_TELEGRAM_BOT_TOKEN", "")
|
|
273
285
|
_tg_chat = os.getenv("LATTICEAI_TELEGRAM_CHAT_ID", "")
|
|
274
|
-
if _tg_token and _tg_chat:
|
|
286
|
+
if _tg_enabled and _tg_token and _tg_chat:
|
|
275
287
|
_local_msg = (
|
|
276
288
|
f"✅ Lattice AI 시작됨\n\n"
|
|
277
289
|
f"🏠 로컬: http://localhost:{args.port}"
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "4.
|
|
4
|
-
"description": "Lattice AI — local-first Digital Brain Platform (knowledge graph, durable memory, hybrid search, agents,
|
|
3
|
+
"version": "4.3.1",
|
|
4
|
+
"description": "Lattice AI — local-first Digital Brain Platform (knowledge graph, durable memory, hybrid search, agents, portable encrypted brain archives)",
|
|
5
5
|
"homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"desktop:tauri:check": "cd src-tauri && cargo check",
|
|
42
42
|
"desktop:electron": "electron desktop/electron/main.cjs",
|
|
43
43
|
"package:vsix": "node scripts/build_vsix.mjs",
|
|
44
|
-
"release:artifacts": "npm run build:assets && npm run build:python && npm pack && npm run package:vsix",
|
|
45
|
-
"release:validate": "node scripts/run_python.mjs scripts/validate_release_artifacts.py $npm_package_version --require-vsix --require-tgz",
|
|
44
|
+
"release:artifacts": "node scripts/clean_release_artifacts.mjs $npm_package_version && npm run build:assets && npm run build:python && npm pack && npm run package:vsix && npm run desktop:tauri:build",
|
|
45
|
+
"release:validate": "node scripts/run_python.mjs scripts/validate_release_artifacts.py $npm_package_version --require-vsix --require-tgz --require-dmg",
|
|
46
46
|
"publish:npm": "npm pack && npm publish ltcai-$npm_package_version.tgz --access public",
|
|
47
47
|
"publish:pypi": "npm run build:python && node scripts/run_python.mjs -m twine upload --skip-existing dist/ltcai-$npm_package_version.tar.gz dist/ltcai-$npm_package_version-py3-none-any.whl",
|
|
48
48
|
"publish:vscode": "cd vscode-extension && npm run package:vsix && npm run publish:vscode",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"files": [
|
|
69
69
|
"bin/ltcai.js",
|
|
70
70
|
"LICENSE",
|
|
71
|
+
"requirements.txt",
|
|
71
72
|
"ltcai_cli.py",
|
|
72
73
|
"auto_setup.py",
|
|
73
74
|
"server.py",
|
package/requirements.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
fastapi>=0.110,<1
|
|
2
|
+
uvicorn>=0.29,<1
|
|
3
|
+
pydantic>=2.7,<3
|
|
4
|
+
httpx>=0.27,<1
|
|
5
|
+
pillow>=10,<13
|
|
6
|
+
openai>=1.30,<3
|
|
7
|
+
python-docx>=1.1,<2
|
|
8
|
+
openpyxl>=3.1,<4
|
|
9
|
+
python-pptx>=0.6.23,<2
|
|
10
|
+
python-multipart>=0.0.9,<0.1
|
|
11
|
+
keyring>=24,<26
|
|
12
|
+
authlib>=1.3,<2
|
|
13
|
+
cryptography>=42,<49
|
|
14
|
+
pdfplumber>=0.11,<0.12
|
|
15
|
+
pypdfium2>=4.30,<6
|
|
16
|
+
watchdog>=4,<7
|
|
17
|
+
psycopg[binary]>=3.2,<4
|