ltcai 4.1.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 (76) hide show
  1. package/README.md +33 -24
  2. package/docs/CHANGELOG.md +84 -0
  3. package/docs/V4_2_BRAIN_CORE_ARCHITECTURE.md +97 -0
  4. package/docs/V4_2_STORAGE_MIGRATION_REPORT.md +91 -0
  5. package/docs/V4_2_VALIDATION_REPORT.md +89 -0
  6. package/docs/V4_3_PORTABILITY_ARCHITECTURE.md +69 -0
  7. package/docs/V4_3_PRIVACY_AUDIT.md +60 -0
  8. package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +53 -0
  9. package/docs/V4_3_VALIDATION_REPORT.md +58 -0
  10. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +31 -33
  11. package/frontend/openapi.json +449 -1
  12. package/frontend/src/api/client.ts +10 -0
  13. package/frontend/src/api/openapi.ts +542 -0
  14. package/frontend/src/pages/System.tsx +92 -0
  15. package/kg_schema.py +1 -1
  16. package/knowledge_graph.py +4 -4
  17. package/lattice_brain/__init__.py +70 -0
  18. package/lattice_brain/_kg_common.py +1 -0
  19. package/lattice_brain/archive.py +446 -0
  20. package/lattice_brain/context.py +3 -0
  21. package/lattice_brain/conversations.py +3 -0
  22. package/lattice_brain/core.py +82 -0
  23. package/lattice_brain/discovery.py +1 -0
  24. package/lattice_brain/documents.py +1 -0
  25. package/lattice_brain/embeddings.py +82 -0
  26. package/lattice_brain/identity.py +13 -0
  27. package/lattice_brain/ingest.py +1 -0
  28. package/lattice_brain/memory.py +3 -0
  29. package/lattice_brain/network.py +1 -0
  30. package/lattice_brain/projection.py +1 -0
  31. package/lattice_brain/provenance.py +1 -0
  32. package/lattice_brain/retrieval.py +1 -0
  33. package/lattice_brain/schema.py +1 -0
  34. package/lattice_brain/storage/__init__.py +22 -0
  35. package/lattice_brain/storage/base.py +72 -0
  36. package/lattice_brain/storage/docker.py +105 -0
  37. package/lattice_brain/storage/factory.py +31 -0
  38. package/lattice_brain/storage/migration.py +190 -0
  39. package/lattice_brain/storage/postgres.py +123 -0
  40. package/lattice_brain/storage/sqlite.py +128 -0
  41. package/lattice_brain/store.py +3 -0
  42. package/lattice_brain/write_master.py +1 -0
  43. package/latticeai/__init__.py +1 -1
  44. package/latticeai/api/admin.py +11 -0
  45. package/latticeai/api/portability.py +127 -1
  46. package/latticeai/app_factory.py +26 -10
  47. package/latticeai/brain/__init__.py +6 -6
  48. package/latticeai/brain/_kg_common.py +1 -1
  49. package/latticeai/brain/network.py +1 -1
  50. package/latticeai/brain/retrieval.py +15 -0
  51. package/latticeai/brain/store.py +22 -6
  52. package/latticeai/core/config.py +9 -1
  53. package/latticeai/core/marketplace.py +1 -1
  54. package/latticeai/core/multi_agent.py +1 -1
  55. package/latticeai/core/product_hardening.py +217 -0
  56. package/latticeai/core/workspace_os.py +1 -1
  57. package/latticeai/services/kg_portability.py +227 -3
  58. package/ltcai_cli.py +2 -1
  59. package/package.json +4 -3
  60. package/scripts/bump_version.py +3 -0
  61. package/scripts/clean_release_artifacts.mjs +27 -0
  62. package/scripts/lint_frontend.mjs +10 -0
  63. package/scripts/migrate_brain_storage.py +53 -0
  64. package/scripts/validate_release_artifacts.py +10 -0
  65. package/scripts/wheel_smoke.py +3 -0
  66. package/src-tauri/Cargo.lock +1 -1
  67. package/src-tauri/Cargo.toml +1 -1
  68. package/src-tauri/src/main.rs +113 -13
  69. package/src-tauri/tauri.conf.json +5 -2
  70. package/static/app/asset-manifest.json +5 -5
  71. package/static/app/assets/{index-CJRAzNnf.js → index-RiJTJliG.js} +3 -3
  72. package/static/app/assets/index-RiJTJliG.js.map +1 -0
  73. package/static/app/assets/index-yZswHE3d.css +2 -0
  74. package/static/app/index.html +2 -2
  75. package/static/app/assets/index-CJRAzNnf.js.map +0 -1
  76. package/static/app/assets/index-CSwBBgf4.css +0 -2
@@ -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.1.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.1.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.1.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,9 +19,16 @@ 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
+ from lattice_brain.archive import BrainArchivePaths, EncryptedBrainArchive
26
+ from lattice_brain.storage import (
27
+ DockerPostgresWizard,
28
+ PostgresEngine,
29
+ SQLiteToPostgresMigrator,
30
+ )
31
+
25
32
  FORMAT = "latticeai.kg.export"
26
33
  FORMAT_VERSION = 1
27
34
  BACKUP_FORMAT = "latticeai.kg.backup"
@@ -43,6 +50,13 @@ def _sha256_file(path: Path) -> str:
43
50
  return h.hexdigest()
44
51
 
45
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
+
46
60
  class KGPortabilityService:
47
61
  def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True, device_identity: Any = None) -> None:
48
62
  self._kg = knowledge_graph
@@ -95,7 +109,7 @@ class KGPortabilityService:
95
109
  origin = "unsigned-legacy"
96
110
  signature = artifact.get("signature")
97
111
  if signature:
98
- from latticeai.brain.identity import verify_manifest
112
+ from lattice_brain.identity import verify_manifest
99
113
 
100
114
  if not verify_manifest(artifact.get("header") or {}, signature):
101
115
  raise ValueError("Bundle signature verification failed — refusing to import.")
@@ -149,13 +163,23 @@ class KGPortabilityService:
149
163
  zf.write(f, f"blobs/{f.relative_to(blob_dir)}")
150
164
  return {"path": str(dest), "bytes": dest.stat().st_size, "manifest": manifest}
151
165
 
152
- 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]:
153
174
  self._require()
154
175
  archive = Path(archive_path)
155
176
  if not archive.exists():
156
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.")
157
180
  with zipfile.ZipFile(archive) as zf:
158
181
  names = zf.namelist()
182
+ _safe_zip_names(names)
159
183
  if "knowledge_graph.sqlite" not in names:
160
184
  raise ValueError("Archive is missing knowledge_graph.sqlite.")
161
185
  manifest = json.loads(zf.read("manifest.json")) if "manifest.json" in names else {}
@@ -166,6 +190,18 @@ class KGPortabilityService:
166
190
  if verify and manifest.get("db_sha256"):
167
191
  if _sha256_file(db_src) != manifest["db_sha256"]:
168
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
+ }
169
205
  db_dest = Path(self._kg.db_path)
170
206
  blob_dest = Path(self._kg.blob_dir)
171
207
  db_dest.parent.mkdir(parents=True, exist_ok=True)
@@ -189,6 +225,113 @@ class KGPortabilityService:
189
225
  "nodes": sum(stats.get("nodes", {}).values()),
190
226
  }
191
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
+
249
+ # ── encrypted .latticebrain archive ───────────────────────────────────
250
+ def encrypted_archive(self, dest_path=None, *, passphrase: str) -> Dict[str, Any]:
251
+ self._require()
252
+ self._exports_dir.mkdir(parents=True, exist_ok=True)
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
+ }
260
+ archive = EncryptedBrainArchive(
261
+ BrainArchivePaths(
262
+ db_path=Path(self._kg.db_path),
263
+ blob_dir=Path(self._kg.blob_dir),
264
+ data_dir=self._data_dir,
265
+ metadata=metadata,
266
+ )
267
+ )
268
+ return archive.create(dest, passphrase=passphrase)
269
+
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]:
298
+ self._require()
299
+ archive = EncryptedBrainArchive(
300
+ BrainArchivePaths(
301
+ db_path=Path(self._kg.db_path),
302
+ blob_dir=Path(self._kg.blob_dir),
303
+ data_dir=self._data_dir,
304
+ )
305
+ )
306
+ return archive.restore(
307
+ Path(archive_path),
308
+ passphrase=passphrase,
309
+ target=BrainArchivePaths(
310
+ db_path=Path(self._kg.db_path),
311
+ blob_dir=Path(self._kg.blob_dir),
312
+ data_dir=self._data_dir,
313
+ ),
314
+ dry_run=dry_run,
315
+ confirm=confirm,
316
+ )
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
+
192
335
  # ── status surface ───────────────────────────────────────────────────────
193
336
  def snapshot_metadata(self) -> Dict[str, Any]:
194
337
  if not self.available():
@@ -198,8 +341,89 @@ class KGPortabilityService:
198
341
  **self._kg.schema_versions(),
199
342
  "stats": self._kg.stats(),
200
343
  "provenance": self._kg.provenance_stats(),
344
+ "storage": (
345
+ self._kg.storage_engine.capabilities().as_dict()
346
+ if getattr(self._kg, "storage_engine", None) is not None
347
+ else {"engine": "sqlite", "available": True}
348
+ ),
201
349
  }
202
350
 
351
+ def storage_status(self) -> Dict[str, Any]:
352
+ if not self.available():
353
+ return {"available": False}
354
+ return {
355
+ "available": True,
356
+ "active": (
357
+ self._kg.storage_engine.capabilities().as_dict()
358
+ if getattr(self._kg, "storage_engine", None) is not None
359
+ else {"engine": "sqlite", "available": True}
360
+ ),
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"),
384
+ }
385
+
386
+ def postgres_docker_setup(
387
+ self,
388
+ *,
389
+ consent: bool,
390
+ dry_run: bool = False,
391
+ port: int = 5432,
392
+ ) -> Dict[str, Any]:
393
+ wizard = DockerPostgresWizard(self._data_dir / "postgres", port=port)
394
+ return wizard.start(consent=consent, dry_run=dry_run)
395
+
396
+ def migrate_sqlite_to_postgres(
397
+ self,
398
+ *,
399
+ dsn: str,
400
+ schema: str = "lattice_brain",
401
+ dry_run: bool = True,
402
+ ) -> Dict[str, Any]:
403
+ self._require()
404
+ if not dsn:
405
+ raise ValueError("Postgres DSN is required for SQLite to Postgres migration.")
406
+ migrator = SQLiteToPostgresMigrator(
407
+ Path(self._kg.db_path),
408
+ PostgresEngine(dsn, schema=schema),
409
+ )
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
426
+
203
427
  def recent_ingestions(self, *, limit: int = 50, source_type: Optional[str] = None) -> Dict[str, Any]:
204
428
  """Recent provenance records (newest first) for the ingestion-sources UI."""
205
429
  if not self.available():
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.1.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",
@@ -80,6 +80,7 @@
80
80
  "tools/",
81
81
  "mcp_registry.py",
82
82
  "latticeai/**/*.py",
83
+ "lattice_brain/**/*.py",
83
84
  "skills/",
84
85
  "static/favicon.ico",
85
86
  "static/manifest.json",
@@ -23,6 +23,7 @@ REPO = Path(__file__).resolve().parents[1]
23
23
  # (path, kind, pattern) — pattern groups: (prefix, version)
24
24
  TARGETS = [
25
25
  ("latticeai/__init__.py", "regex", r'(__version__ = ")([^"]+)(")'),
26
+ ("lattice_brain/__init__.py", "regex", r'(__version__ = ")([^"]+)(")'),
26
27
  ("latticeai/core/workspace_os.py", "regex", r'(WORKSPACE_OS_VERSION = ")([^"]+)(")'),
27
28
  ("latticeai/core/marketplace.py", "regex", r'(MARKETPLACE_VERSION = ")([^"]+)(")'),
28
29
  ("latticeai/core/multi_agent.py", "regex", r'(MULTI_AGENT_VERSION = ")([^"]+)(")'),
@@ -31,6 +32,8 @@ TARGETS = [
31
32
  ("package-lock.json", "package-lock", None),
32
33
  ("vscode-extension/package.json", "json", "version"),
33
34
  ("vscode-extension/package-lock.json", "package-lock", None),
35
+ ("src-tauri/Cargo.toml", "regex", r'(^version = ")([^"]+)(")'),
36
+ ("src-tauri/tauri.conf.json", "json", "version"),
34
37
  ("static/app/asset-manifest.json", "json", "version"),
35
38
  ]
36
39
 
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const repo = join(import.meta.dirname, "..");
6
+ const version = process.argv[2] || process.env.npm_package_version;
7
+
8
+ if (!version || !/^\d+\.\d+\.\d+([.-][0-9A-Za-z.]+)?$/.test(version)) {
9
+ console.error("usage: node scripts/clean_release_artifacts.mjs <version>");
10
+ process.exit(2);
11
+ }
12
+
13
+ const targets = [
14
+ join(repo, "dist", `ltcai-${version}-py3-none-any.whl`),
15
+ join(repo, "dist", `ltcai-${version}.tar.gz`),
16
+ join(repo, "dist", `ltcai-${version}.vsix`),
17
+ join(repo, `ltcai-${version}.tgz`),
18
+ join(repo, "src-tauri", "target", "release", "bundle", "dmg", `Lattice AI_${version}_aarch64.dmg`),
19
+ join(repo, "src-tauri", "target", "release", "bundle", "macos", "Lattice AI.app"),
20
+ ];
21
+
22
+ for (const target of targets) {
23
+ if (existsSync(target)) {
24
+ rmSync(target, { recursive: true, force: true });
25
+ console.log(`removed ${target}`);
26
+ }
27
+ }
@@ -72,6 +72,16 @@ const requiredPaths = [
72
72
  "/workflows/api/definitions",
73
73
  "/workspace/os",
74
74
  "/models",
75
+ "/api/brain/storage",
76
+ "/api/brain/storage/postgres/docker",
77
+ "/api/brain/storage/migrate-postgres",
78
+ "/api/knowledge-graph/archive",
79
+ "/api/knowledge-graph/archive/inspect",
80
+ "/api/knowledge-graph/archive/verify",
81
+ "/api/knowledge-graph/archive/import",
82
+ "/api/knowledge-graph/archive/restore",
83
+ "/api/knowledge-graph/backup-health",
84
+ "/admin/product-hardening",
75
85
  ];
76
86
  if (openapiPaths.length < 300) fail(`OpenAPI path count too low: ${openapiPaths.length}`);
77
87
  for (const path of requiredPaths) {