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.
Files changed (51) hide show
  1. package/README.md +28 -21
  2. package/bin/ltcai.js +6 -2
  3. package/docs/CHANGELOG.md +72 -0
  4. package/docs/V4_3_PORTABILITY_ARCHITECTURE.md +69 -0
  5. package/docs/V4_3_PRIVACY_AUDIT.md +60 -0
  6. package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +53 -0
  7. package/docs/V4_3_VALIDATION_REPORT.md +58 -0
  8. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +19 -25
  9. package/frontend/openapi.json +213 -1
  10. package/frontend/src/App.tsx +15 -1
  11. package/frontend/src/api/client.ts +26 -1
  12. package/frontend/src/api/openapi.ts +268 -0
  13. package/frontend/src/pages/Act.tsx +63 -2
  14. package/frontend/src/pages/Library.tsx +9 -3
  15. package/frontend/src/pages/System.tsx +58 -0
  16. package/lattice_brain/__init__.py +1 -1
  17. package/lattice_brain/archive.py +360 -47
  18. package/lattice_brain/storage/sqlite.py +15 -2
  19. package/latticeai/__init__.py +1 -1
  20. package/latticeai/api/admin.py +11 -0
  21. package/latticeai/api/agents.py +3 -1
  22. package/latticeai/api/models.py +66 -18
  23. package/latticeai/api/portability.py +59 -2
  24. package/latticeai/app_factory.py +9 -0
  25. package/latticeai/brain/projection.py +12 -2
  26. package/latticeai/brain/retrieval.py +10 -0
  27. package/latticeai/brain/store.py +6 -1
  28. package/latticeai/core/config.py +4 -2
  29. package/latticeai/core/marketplace.py +1 -1
  30. package/latticeai/core/multi_agent.py +1 -1
  31. package/latticeai/core/product_hardening.py +218 -0
  32. package/latticeai/core/workspace_os.py +1 -1
  33. package/latticeai/services/agent_runtime.py +52 -12
  34. package/latticeai/services/kg_portability.py +147 -4
  35. package/latticeai/services/model_runtime.py +83 -2
  36. package/ltcai_cli.py +16 -4
  37. package/package.json +5 -4
  38. package/requirements.txt +17 -0
  39. package/scripts/clean_release_artifacts.mjs +27 -0
  40. package/scripts/lint_frontend.mjs +5 -0
  41. package/scripts/validate_release_artifacts.py +10 -0
  42. package/src-tauri/Cargo.lock +1 -1
  43. package/src-tauri/Cargo.toml +1 -1
  44. package/src-tauri/src/main.rs +356 -24
  45. package/src-tauri/tauri.conf.json +20 -1
  46. package/static/app/asset-manifest.json +5 -5
  47. package/static/app/assets/{index-C_HAkbAg.js → index-BhPuj8rT.js} +45 -45
  48. package/static/app/assets/index-BhPuj8rT.js.map +1 -0
  49. package/static/app/assets/{index-CDjiH_se.css → index-yZswHE3d.css} +1 -1
  50. package/static/app/index.html +2 -2
  51. 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
- checks["orchestrator"] = {"status": "ok"}
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 {"status": "ok" if ok else "degraded", "checks": checks}
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": True,
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": self.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
- try:
292
- orchestrator = self._orchestrator_factory(user_email or None, scope)
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._orchestrator_factory(user_email or None, scope)
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._orchestrator_factory(user_email or None, scope)
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(self, archive_path, *, verify: bool = True) -> Dict[str, Any]:
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 restore_encrypted_archive(self, archive_path, *, passphrase: str) -> Dict[str, Any]:
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
- return migrator.migrate(dry_run=dry_run)
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": True,
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": True,
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
- # LATTICEAI_TUNNEL=true in .env acts like --tunnel flag
250
- if not args.tunnel and os.getenv("LATTICEAI_TUNNEL", "").lower() in ("1", "true", "yes"):
251
- args.tunnel = True
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.2.0",
4
- "description": "Lattice AI — local-first Digital Brain Platform (knowledge graph, durable memory, hybrid search, agents, signed brain exchange)",
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",
@@ -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