ltcai 4.2.0 → 4.3.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 (38) hide show
  1. package/README.md +26 -21
  2. package/docs/CHANGELOG.md +42 -0
  3. package/docs/V4_3_PORTABILITY_ARCHITECTURE.md +69 -0
  4. package/docs/V4_3_PRIVACY_AUDIT.md +60 -0
  5. package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +53 -0
  6. package/docs/V4_3_VALIDATION_REPORT.md +58 -0
  7. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +25 -25
  8. package/frontend/openapi.json +203 -1
  9. package/frontend/src/api/client.ts +7 -0
  10. package/frontend/src/api/openapi.ts +258 -0
  11. package/frontend/src/pages/System.tsx +58 -0
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/archive.py +360 -47
  14. package/latticeai/__init__.py +1 -1
  15. package/latticeai/api/admin.py +11 -0
  16. package/latticeai/api/portability.py +59 -2
  17. package/latticeai/app_factory.py +9 -0
  18. package/latticeai/core/config.py +1 -1
  19. package/latticeai/core/marketplace.py +1 -1
  20. package/latticeai/core/multi_agent.py +1 -1
  21. package/latticeai/core/product_hardening.py +217 -0
  22. package/latticeai/core/workspace_os.py +1 -1
  23. package/latticeai/services/kg_portability.py +147 -4
  24. package/ltcai_cli.py +2 -1
  25. package/package.json +3 -3
  26. package/scripts/clean_release_artifacts.mjs +27 -0
  27. package/scripts/lint_frontend.mjs +5 -0
  28. package/scripts/validate_release_artifacts.py +10 -0
  29. package/src-tauri/Cargo.lock +1 -1
  30. package/src-tauri/Cargo.toml +1 -1
  31. package/src-tauri/src/main.rs +113 -13
  32. package/src-tauri/tauri.conf.json +1 -1
  33. package/static/app/asset-manifest.json +5 -5
  34. package/static/app/assets/{index-C_HAkbAg.js → index-RiJTJliG.js} +3 -3
  35. package/static/app/assets/index-RiJTJliG.js.map +1 -0
  36. package/static/app/assets/{index-CDjiH_se.css → index-yZswHE3d.css} +1 -1
  37. package/static/app/index.html +2 -2
  38. package/static/app/assets/index-C_HAkbAg.js.map +0 -1
@@ -26,6 +26,8 @@ class BackupRequest(BaseModel):
26
26
  class RestoreRequest(BaseModel):
27
27
  path: str
28
28
  verify: bool = True
29
+ dry_run: bool = False
30
+ confirm: bool = False
29
31
 
30
32
 
31
33
  class EncryptedArchiveRequest(BaseModel):
@@ -36,6 +38,18 @@ class EncryptedArchiveRequest(BaseModel):
36
38
  class EncryptedRestoreRequest(BaseModel):
37
39
  path: str
38
40
  passphrase: str
41
+ dry_run: bool = False
42
+ confirm: bool = False
43
+
44
+
45
+ class EncryptedInspectRequest(BaseModel):
46
+ path: str
47
+ passphrase: Optional[str] = None
48
+
49
+
50
+ class EncryptedVerifyRequest(BaseModel):
51
+ path: str
52
+ passphrase: str
39
53
 
40
54
 
41
55
  class DockerPostgresRequest(BaseModel):
@@ -74,6 +88,12 @@ def create_portability_router(
74
88
  _require_service()
75
89
  return service.storage_status()
76
90
 
91
+ @router.get("/api/knowledge-graph/backup-health")
92
+ async def backup_health(request: Request):
93
+ require_user(request)
94
+ _require_service()
95
+ return service.backup_health()
96
+
77
97
  @router.get("/api/knowledge-graph/provenance")
78
98
  async def recent_provenance(request: Request, limit: int = 50, source_type: Optional[str] = None):
79
99
  """Recent ingestions (provenance trail) for the ingestion-sources UI."""
@@ -114,7 +134,7 @@ def create_portability_router(
114
134
  require_admin(request)
115
135
  _require_service()
116
136
  try:
117
- return service.restore(req.path, verify=req.verify)
137
+ return service.restore(req.path, verify=req.verify, dry_run=req.dry_run, confirm=req.confirm)
118
138
  except (ValueError, FileNotFoundError) as exc:
119
139
  raise HTTPException(status_code=400, detail=str(exc))
120
140
 
@@ -127,12 +147,49 @@ def create_portability_router(
127
147
  except (ValueError, FileNotFoundError) as exc:
128
148
  raise HTTPException(status_code=400, detail=str(exc))
129
149
 
150
+ @router.post("/api/knowledge-graph/archive/inspect")
151
+ async def inspect_encrypted_archive(req: EncryptedInspectRequest, request: Request):
152
+ require_admin(request)
153
+ _require_service()
154
+ try:
155
+ return service.inspect_encrypted_archive(req.path, passphrase=req.passphrase)
156
+ except (ValueError, FileNotFoundError) as exc:
157
+ raise HTTPException(status_code=400, detail=str(exc))
158
+
159
+ @router.post("/api/knowledge-graph/archive/verify")
160
+ async def verify_encrypted_archive(req: EncryptedVerifyRequest, request: Request):
161
+ require_admin(request)
162
+ _require_service()
163
+ result = service.verify_encrypted_archive(req.path, passphrase=req.passphrase)
164
+ if not result.get("ok"):
165
+ raise HTTPException(status_code=400, detail="; ".join(result.get("errors") or ["Archive verification failed."]))
166
+ return result
167
+
168
+ @router.post("/api/knowledge-graph/archive/import")
169
+ async def import_encrypted_archive(req: EncryptedRestoreRequest, request: Request):
170
+ require_admin(request)
171
+ _require_service()
172
+ try:
173
+ return service.import_encrypted_archive(
174
+ req.path,
175
+ passphrase=req.passphrase,
176
+ dry_run=req.dry_run,
177
+ confirm=req.confirm,
178
+ )
179
+ except (ValueError, FileNotFoundError) as exc:
180
+ raise HTTPException(status_code=400, detail=str(exc))
181
+
130
182
  @router.post("/api/knowledge-graph/archive/restore")
131
183
  async def restore_encrypted_archive(req: EncryptedRestoreRequest, request: Request):
132
184
  require_admin(request)
133
185
  _require_service()
134
186
  try:
135
- return service.restore_encrypted_archive(req.path, passphrase=req.passphrase)
187
+ return service.restore_encrypted_archive(
188
+ req.path,
189
+ passphrase=req.passphrase,
190
+ dry_run=req.dry_run,
191
+ confirm=req.confirm,
192
+ )
136
193
  except (ValueError, FileNotFoundError) as exc:
137
194
  raise HTTPException(status_code=400, detail=str(exc))
138
195
 
@@ -154,6 +154,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
154
154
  from latticeai.api.hooks import create_hooks_router
155
155
  from latticeai.core.hooks import HooksRegistry
156
156
  from latticeai.core.builtin_hooks import register_builtin_hook_runners
157
+ from latticeai.core.product_hardening import build_product_hardening_status
157
158
  from latticeai.api.agent_registry import create_agent_registry_router
158
159
  from latticeai.core.agent_registry import AgentRegistry
159
160
  from latticeai.api.memory import create_memory_router
@@ -1256,6 +1257,13 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1256
1257
  except Exception as e:
1257
1258
  return {"error": str(e)}
1258
1259
 
1260
+ def _product_hardening_status():
1261
+ return build_product_hardening_status(
1262
+ config=CONFIG,
1263
+ portability=KG_PORTABILITY,
1264
+ device_identity=DEVICE_IDENTITY,
1265
+ )
1266
+
1259
1267
  app.include_router(create_admin_router(
1260
1268
  require_admin=require_admin, require_user=require_user,
1261
1269
  load_users=load_users, save_users=save_users,
@@ -1270,6 +1278,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
1270
1278
  invite_code=INVITE_CODE, invite_gate_enabled=INVITE_GATE_ENABLED,
1271
1279
  default_port=DEFAULT_PORT,
1272
1280
  policy_matrix=policy_matrix,
1281
+ product_hardening_status=_product_hardening_status,
1273
1282
  ))
1274
1283
 
1275
1284
  app.include_router(create_invitations_router(
@@ -163,7 +163,7 @@ class Config:
163
163
  host=host,
164
164
  port=port,
165
165
  network_exposed=network_exposed,
166
- enable_telegram=_bool(env, "LATTICEAI_ENABLE_TELEGRAM", default=not is_public),
166
+ enable_telegram=_bool(env, "LATTICEAI_ENABLE_TELEGRAM", default=False),
167
167
  enable_graph=_bool(env, "LATTICEAI_ENABLE_GRAPH", default=True),
168
168
  autoload_models=_bool(env, "LATTICEAI_AUTOLOAD_MODELS", default=is_public),
169
169
  model_idle_unload_seconds=_int(env, "LATTICEAI_MODEL_IDLE_UNLOAD_SECONDS", 0),
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "4.2.0"
14
+ MARKETPLACE_VERSION = "4.3.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
16
 
17
17
 
@@ -14,7 +14,7 @@ from datetime import datetime
14
14
  from typing import Any, Callable, Dict, List, Optional
15
15
 
16
16
 
17
- MULTI_AGENT_VERSION = "4.2.0"
17
+ MULTI_AGENT_VERSION = "4.3.0"
18
18
 
19
19
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
20
20
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -0,0 +1,217 @@
1
+ """Product hardening and privacy status helpers.
2
+
3
+ These helpers are read-only and must not perform network probes. They describe
4
+ the local-first startup posture and distinguish available credentials from
5
+ enabled outbound communication.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Mapping, Optional
14
+
15
+ from latticeai.core.config import Config
16
+
17
+
18
+ def _bool(env: Mapping[str, str], key: str, default: bool = False) -> bool:
19
+ raw = env.get(key)
20
+ if raw is None:
21
+ return default
22
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
23
+
24
+
25
+ def _present(env: Mapping[str, str], *keys: str) -> bool:
26
+ return any(bool(str(env.get(key) or "").strip()) for key in keys)
27
+
28
+
29
+ def external_integration_status(
30
+ config: Config,
31
+ *,
32
+ env: Optional[Mapping[str, str]] = None,
33
+ ) -> Dict[str, Any]:
34
+ if env is None:
35
+ env = os.environ
36
+ telegram_credentials = _present(env, "LATTICEAI_TELEGRAM_BOT_TOKEN", "TELEGRAM_BOT_TOKEN")
37
+ brain_network_auto_push = _bool(env, "LATTICEAI_BRAIN_NETWORK_AUTO_PUSH", default=False)
38
+ updater_enabled = _bool(env, "LATTICEAI_ENABLE_UPDATES", default=False)
39
+ model_downloads_enabled = _bool(env, "LATTICEAI_ALLOW_MODEL_DOWNLOADS", default=False) or bool(config.autoload_models)
40
+ docker_auto_start = _bool(env, "LATTICEAI_DOCKER_AUTO_START", default=False)
41
+ external_connectors_enabled = _bool(env, "LATTICEAI_ENABLE_EXTERNAL_CONNECTORS", default=False)
42
+ postgres_enabled = config.storage_engine == "postgres" and bool(config.postgres_dsn)
43
+ return {
44
+ "local_only_default": default_startup_local_only(config, env=env),
45
+ "integrations": {
46
+ "telegram": {
47
+ "enabled": bool(config.enable_telegram),
48
+ "credential_present": telegram_credentials,
49
+ "opt_in_required": True,
50
+ "automatic_egress": bool(config.enable_telegram),
51
+ "detail": (
52
+ "enabled by LATTICEAI_ENABLE_TELEGRAM"
53
+ if config.enable_telegram
54
+ else "disabled; token presence alone does not start Telegram"
55
+ ),
56
+ },
57
+ "brain_network": {
58
+ "enabled": brain_network_auto_push,
59
+ "credential_present": False,
60
+ "opt_in_required": True,
61
+ "automatic_egress": brain_network_auto_push,
62
+ "detail": "peer pushes are user/admin initiated; no automatic peer sync by default",
63
+ },
64
+ "updates": {
65
+ "enabled": updater_enabled,
66
+ "credential_present": False,
67
+ "opt_in_required": True,
68
+ "automatic_egress": updater_enabled,
69
+ "detail": "desktop updater checks are disabled unless LATTICEAI_ENABLE_UPDATES is true",
70
+ },
71
+ "model_downloads": {
72
+ "enabled": model_downloads_enabled,
73
+ "credential_present": _present(env, "HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN"),
74
+ "opt_in_required": True,
75
+ "automatic_egress": bool(config.autoload_models),
76
+ "detail": "model downloads require an explicit load/autoload setting",
77
+ },
78
+ "docker": {
79
+ "enabled": docker_auto_start,
80
+ "credential_present": False,
81
+ "opt_in_required": True,
82
+ "automatic_egress": docker_auto_start,
83
+ "detail": "Docker setup requires explicit runtime consent; auto-start is disabled by default",
84
+ },
85
+ "postgres": {
86
+ "enabled": postgres_enabled,
87
+ "credential_present": bool(config.postgres_dsn),
88
+ "opt_in_required": True,
89
+ "automatic_egress": postgres_enabled,
90
+ "detail": "Postgres scale mode is used only when storage engine and DSN are explicitly configured",
91
+ },
92
+ "external_connectors": {
93
+ "enabled": external_connectors_enabled,
94
+ "credential_present": _present(
95
+ env,
96
+ "OPENAI_API_KEY",
97
+ "ANTHROPIC_API_KEY",
98
+ "GITHUB_TOKEN",
99
+ "SLACK_BOT_TOKEN",
100
+ "DISCORD_BOT_TOKEN",
101
+ ),
102
+ "opt_in_required": True,
103
+ "automatic_egress": external_connectors_enabled,
104
+ "detail": "connector credentials are inert until the connector is explicitly enabled and invoked",
105
+ },
106
+ },
107
+ }
108
+
109
+
110
+ def default_startup_local_only(
111
+ config: Config,
112
+ *,
113
+ env: Optional[Mapping[str, str]] = None,
114
+ ) -> bool:
115
+ if env is None:
116
+ env = os.environ
117
+ local_embedding = config.embedding_provider in {"", "hash", "local", "fallback", "sqlite"}
118
+ external = external_integration_status_no_recurse(config, env=env)
119
+ return (
120
+ not config.network_exposed
121
+ and not config.cors_allow_network
122
+ and not config.enable_telegram
123
+ and not config.autoload_models
124
+ and config.storage_engine == "sqlite"
125
+ and local_embedding
126
+ and not any(item["automatic_egress"] for item in external.values())
127
+ )
128
+
129
+
130
+ def external_integration_status_no_recurse(
131
+ config: Config,
132
+ *,
133
+ env: Mapping[str, str],
134
+ ) -> Dict[str, Dict[str, Any]]:
135
+ return {
136
+ "brain_network": {"automatic_egress": _bool(env, "LATTICEAI_BRAIN_NETWORK_AUTO_PUSH", default=False)},
137
+ "updates": {"automatic_egress": _bool(env, "LATTICEAI_ENABLE_UPDATES", default=False)},
138
+ "docker": {"automatic_egress": _bool(env, "LATTICEAI_DOCKER_AUTO_START", default=False)},
139
+ "postgres": {"automatic_egress": config.storage_engine == "postgres" and bool(config.postgres_dsn)},
140
+ "external_connectors": {"automatic_egress": _bool(env, "LATTICEAI_ENABLE_EXTERNAL_CONNECTORS", default=False)},
141
+ }
142
+
143
+
144
+ def build_product_hardening_status(
145
+ *,
146
+ config: Config,
147
+ portability: Any = None,
148
+ device_identity: Any = None,
149
+ env: Optional[Mapping[str, str]] = None,
150
+ ) -> Dict[str, Any]:
151
+ if env is None:
152
+ env = os.environ
153
+ storage = {"available": False}
154
+ backup = {"available": False}
155
+ if portability is not None and getattr(portability, "available", lambda: False)():
156
+ storage = portability.storage_status()
157
+ backup = portability.backup_health()
158
+ identity = {}
159
+ if device_identity is not None:
160
+ identity = device_identity.describe()
161
+ data_dir = Path(config.data_dir)
162
+ return {
163
+ "version": "4.3.0",
164
+ "startup": {
165
+ "local_only_default": default_startup_local_only(config, env=env),
166
+ "host": config.host,
167
+ "port": config.port,
168
+ "network_exposed": config.network_exposed,
169
+ "auth_required": config.require_auth,
170
+ "cors_network_allowed": config.cors_allow_network,
171
+ },
172
+ "desktop": {
173
+ "sidecar_lifecycle": "managed",
174
+ "restart_supported": True,
175
+ "shutdown_supported": True,
176
+ "updater": {
177
+ "enabled": _bool(env, "LATTICEAI_ENABLE_UPDATES", default=False),
178
+ "limitation": "No external update checks run unless explicitly enabled by policy.",
179
+ },
180
+ },
181
+ "first_run": {
182
+ "data_dir": str(data_dir),
183
+ "data_dir_exists": data_dir.exists(),
184
+ "python_available": shutil.which("python3") is not None or shutil.which("python") is not None,
185
+ "docker_available": shutil.which("docker") is not None,
186
+ "docker_required": False,
187
+ "postgres_required": False,
188
+ },
189
+ "privacy": external_integration_status(config, env=env),
190
+ "storage": storage,
191
+ "backup": backup,
192
+ "device_identity": identity,
193
+ "permissions": {
194
+ "export_requires_admin": True,
195
+ "import_requires_admin": True,
196
+ "restore_requires_admin": True,
197
+ "destructive_restore_requires_confirmation": True,
198
+ "workspace_isolation_enforced": True,
199
+ "audit_log_visible_to_admin": True,
200
+ },
201
+ "failure_policy": {
202
+ "archive_corruption": "fail_closed",
203
+ "partial_archive": "fail_closed",
204
+ "signature_mismatch": "fail_closed",
205
+ "unsupported_version": "fail_closed",
206
+ "missing_docker": "honest_unavailable",
207
+ "missing_postgres": "honest_unavailable",
208
+ "permission_denied": "honest_error",
209
+ },
210
+ }
211
+
212
+
213
+ __all__ = [
214
+ "build_product_hardening_status",
215
+ "default_startup_local_only",
216
+ "external_integration_status",
217
+ ]
@@ -19,7 +19,7 @@ from pathlib import Path
19
19
  from typing import Any, Callable, Dict, Iterable, List, Optional
20
20
 
21
21
 
22
- WORKSPACE_OS_VERSION = "4.2.0"
22
+ WORKSPACE_OS_VERSION = "4.3.0"
23
23
 
24
24
  # Workspace types separate single-user Personal workspaces from shared
25
25
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -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."""
package/ltcai_cli.py CHANGED
@@ -269,9 +269,10 @@ def main() -> None:
269
269
 
270
270
  # Telegram startup notification (local start, tunnel handled separately inside _start_tunnel)
271
271
  if not args.tunnel:
272
+ _tg_enabled = os.getenv("LATTICEAI_ENABLE_TELEGRAM", "").strip().lower() in ("1", "true", "yes", "on")
272
273
  _tg_token = os.getenv("LATTICEAI_TELEGRAM_BOT_TOKEN", "")
273
274
  _tg_chat = os.getenv("LATTICEAI_TELEGRAM_CHAT_ID", "")
274
- if _tg_token and _tg_chat:
275
+ if _tg_enabled and _tg_token and _tg_chat:
275
276
  _local_msg = (
276
277
  f"✅ Lattice AI 시작됨\n\n"
277
278
  f"🏠 로컬: http://localhost:{args.port}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "description": "Lattice AI — local-first Digital Brain Platform (knowledge graph, durable memory, hybrid search, agents, signed brain exchange)",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -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",