ltcai 11.2.0 → 11.4.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 (247) hide show
  1. package/README.md +46 -53
  2. package/docs/CHANGELOG.md +61 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +6 -2
  8. package/docs/PERMISSION_MODE.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +2 -2
  12. package/docs/v11.3.0_PLAN.md +202 -0
  13. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/graph/_kg_common/__init__.py +287 -0
  16. package/lattice_brain/graph/_kg_common/extraction.py +516 -0
  17. package/lattice_brain/graph/_kg_common/relations.py +161 -0
  18. package/lattice_brain/graph/_kg_common/text.py +479 -0
  19. package/lattice_brain/graph/discovery_index/__init__.py +35 -0
  20. package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
  21. package/lattice_brain/graph/discovery_index/extract.py +137 -0
  22. package/lattice_brain/graph/discovery_index/scan.py +411 -0
  23. package/lattice_brain/graph/discovery_index/upsert.py +495 -0
  24. package/lattice_brain/graph/projection/__init__.py +42 -0
  25. package/lattice_brain/graph/projection/curation.py +500 -0
  26. package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +15 -477
  27. package/lattice_brain/graph/retrieval/__init__.py +54 -0
  28. package/lattice_brain/graph/retrieval/context.py +197 -0
  29. package/lattice_brain/graph/retrieval/graph_view.py +319 -0
  30. package/lattice_brain/graph/retrieval/hybrid.py +488 -0
  31. package/lattice_brain/graph/retrieval/maintenance.py +121 -0
  32. package/lattice_brain/graph/retrieval/signals.py +95 -0
  33. package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
  34. package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
  35. package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
  36. package/lattice_brain/graph/retrieval_vector/search.py +560 -0
  37. package/lattice_brain/graph/retrieval_vector/status.py +374 -0
  38. package/lattice_brain/ingestion/__init__.py +130 -0
  39. package/lattice_brain/ingestion/_contract.py +90 -0
  40. package/lattice_brain/ingestion/constants.py +127 -0
  41. package/lattice_brain/ingestion/folder_scan.py +57 -0
  42. package/lattice_brain/ingestion/folders.py +258 -0
  43. package/lattice_brain/ingestion/hashing.py +26 -0
  44. package/lattice_brain/ingestion/jobs_api.py +107 -0
  45. package/lattice_brain/ingestion/models.py +80 -0
  46. package/lattice_brain/ingestion/pipeline.py +486 -0
  47. package/lattice_brain/ingestion/quality.py +209 -0
  48. package/lattice_brain/ingestion/routing.py +295 -0
  49. package/lattice_brain/multimodal/__init__.py +164 -0
  50. package/lattice_brain/multimodal/audio.py +77 -0
  51. package/lattice_brain/multimodal/common.py +118 -0
  52. package/lattice_brain/multimodal/images.py +498 -0
  53. package/lattice_brain/multimodal/ports.py +169 -0
  54. package/lattice_brain/multimodal/video.py +410 -0
  55. package/lattice_brain/portability/__init__.py +90 -0
  56. package/lattice_brain/portability/_contract.py +42 -0
  57. package/lattice_brain/portability/backups.py +338 -0
  58. package/lattice_brain/portability/bundles.py +136 -0
  59. package/lattice_brain/portability/constants.py +93 -0
  60. package/lattice_brain/portability/fsops.py +138 -0
  61. package/lattice_brain/portability/service.py +41 -0
  62. package/lattice_brain/{portability.py → portability/sharing.py} +44 -677
  63. package/lattice_brain/runtime/__init__.py +1 -1
  64. package/lattice_brain/runtime/multi_agent.py +1 -1
  65. package/latticeai/__init__.py +1 -1
  66. package/latticeai/api/chronicle.py +63 -0
  67. package/latticeai/core/agent/__init__.py +93 -0
  68. package/latticeai/core/agent/_contract.py +79 -0
  69. package/latticeai/core/agent/context.py +57 -0
  70. package/latticeai/core/agent/deps.py +125 -0
  71. package/latticeai/core/agent/execution.py +622 -0
  72. package/latticeai/core/agent/planning.py +145 -0
  73. package/latticeai/core/agent/recovery.py +157 -0
  74. package/latticeai/core/agent/runtime.py +210 -0
  75. package/latticeai/core/agent/verification.py +231 -0
  76. package/latticeai/core/embedding_providers/__init__.py +151 -0
  77. package/latticeai/core/embedding_providers/base.py +199 -0
  78. package/latticeai/core/embedding_providers/captions.py +162 -0
  79. package/latticeai/core/embedding_providers/profiles.py +126 -0
  80. package/latticeai/core/embedding_providers/text.py +350 -0
  81. package/latticeai/core/embedding_providers/vision.py +352 -0
  82. package/latticeai/core/file_generation/__init__.py +115 -0
  83. package/latticeai/core/file_generation/bundles.py +76 -0
  84. package/latticeai/core/file_generation/extraction.py +154 -0
  85. package/latticeai/core/file_generation/inference.py +235 -0
  86. package/latticeai/core/file_generation/orchestration.py +152 -0
  87. package/latticeai/core/file_generation/prompting.py +117 -0
  88. package/latticeai/core/file_generation/repair.py +114 -0
  89. package/latticeai/core/file_generation/sanitize.py +61 -0
  90. package/latticeai/core/file_generation/validation.py +201 -0
  91. package/latticeai/core/legacy_compatibility.py +1 -1
  92. package/latticeai/core/marketplace.py +1 -1
  93. package/latticeai/core/messages.py +9 -0
  94. package/latticeai/core/workspace_os_constants.py +1 -1
  95. package/latticeai/integrations/telegram_bot/__init__.py +123 -0
  96. package/latticeai/integrations/telegram_bot/__main__.py +17 -0
  97. package/latticeai/integrations/telegram_bot/config.py +86 -0
  98. package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
  99. package/latticeai/integrations/telegram_bot/flows.py +478 -0
  100. package/latticeai/integrations/telegram_bot/helpers.py +322 -0
  101. package/latticeai/integrations/telegram_bot/screens.py +394 -0
  102. package/latticeai/models/router/__init__.py +88 -0
  103. package/latticeai/models/router/_contract.py +66 -0
  104. package/latticeai/models/router/branding.py +56 -0
  105. package/latticeai/models/router/catalog.py +69 -0
  106. package/latticeai/models/router/documents.py +199 -0
  107. package/latticeai/models/router/errors.py +37 -0
  108. package/latticeai/models/router/generation.py +258 -0
  109. package/latticeai/models/router/loading.py +291 -0
  110. package/latticeai/models/router/local_models.py +85 -0
  111. package/latticeai/models/router/registry.py +147 -0
  112. package/latticeai/runtime/build_phases/__init__.py +82 -0
  113. package/latticeai/runtime/build_phases/features.py +407 -0
  114. package/latticeai/runtime/build_phases/foundation.py +555 -0
  115. package/latticeai/runtime/build_phases/web.py +492 -0
  116. package/latticeai/runtime/runtime_context.py +1 -0
  117. package/latticeai/services/architecture_readiness.py +48 -19
  118. package/latticeai/services/brain_intelligence/__init__.py +58 -0
  119. package/latticeai/services/brain_intelligence/_contract.py +71 -0
  120. package/latticeai/services/brain_intelligence/consistency.py +193 -0
  121. package/latticeai/services/brain_intelligence/constants.py +47 -0
  122. package/latticeai/services/brain_intelligence/digest.py +258 -0
  123. package/latticeai/services/brain_intelligence/health.py +331 -0
  124. package/latticeai/services/brain_intelligence/proposals.py +264 -0
  125. package/latticeai/services/brain_intelligence/sampling.py +84 -0
  126. package/latticeai/services/brain_intelligence/service.py +48 -0
  127. package/latticeai/services/chronicle.py +557 -0
  128. package/latticeai/services/memory_service/__init__.py +52 -0
  129. package/latticeai/services/memory_service/_contract.py +100 -0
  130. package/latticeai/services/memory_service/brief.py +431 -0
  131. package/latticeai/services/memory_service/constants.py +57 -0
  132. package/latticeai/services/memory_service/maintenance.py +138 -0
  133. package/latticeai/services/memory_service/manager.py +186 -0
  134. package/latticeai/services/memory_service/proof.py +136 -0
  135. package/latticeai/services/memory_service/recall.py +225 -0
  136. package/latticeai/services/memory_service/service.py +48 -0
  137. package/latticeai/services/memory_service/stores.py +110 -0
  138. package/latticeai/services/model_runtime/__init__.py +322 -0
  139. package/latticeai/services/model_runtime/cloud.py +87 -0
  140. package/latticeai/services/model_runtime/download.py +282 -0
  141. package/latticeai/services/model_runtime/engines.py +341 -0
  142. package/latticeai/services/model_runtime/loading.py +178 -0
  143. package/latticeai/services/model_runtime/service.py +129 -0
  144. package/latticeai/services/model_runtime/state.py +131 -0
  145. package/latticeai/services/model_runtime/status.py +255 -0
  146. package/latticeai/services/product_readiness.py +15 -7
  147. package/latticeai/setup/wizard/__init__.py +126 -0
  148. package/latticeai/setup/wizard/catalog.py +172 -0
  149. package/latticeai/setup/wizard/detect.py +323 -0
  150. package/latticeai/setup/wizard/install.py +348 -0
  151. package/latticeai/setup/wizard/paths.py +168 -0
  152. package/latticeai/setup/wizard/plans.py +74 -0
  153. package/latticeai/setup/wizard/recommend.py +320 -0
  154. package/package.json +6 -2
  155. package/scripts/bump_version.py +14 -0
  156. package/scripts/capture_release_evidence.mjs +33 -21
  157. package/scripts/check_current_release_docs.mjs +1 -1
  158. package/scripts/check_i18n_namespace_coverage.mjs +41 -4
  159. package/scripts/check_max_file_lines.mjs +102 -0
  160. package/scripts/check_release_evidence_bound.mjs +30 -15
  161. package/scripts/check_screenshot_pixel_delta.py +34 -4
  162. package/scripts/check_server_i18n.mjs +1 -0
  163. package/scripts/generate_rust_parity_fixtures.py +562 -0
  164. package/scripts/lib/mock_server_fingerprint.mjs +94 -0
  165. package/scripts/release_screen_claims.json +31 -2
  166. package/src-tauri/Cargo.lock +361 -3
  167. package/src-tauri/Cargo.toml +6 -1
  168. package/src-tauri/src/backend.rs +349 -0
  169. package/src-tauri/src/folder.rs +33 -0
  170. package/src-tauri/src/main.rs +97 -399
  171. package/src-tauri/tauri.conf.json +1 -1
  172. package/static/app/asset-manifest.json +41 -37
  173. package/static/app/assets/Act-yYpYnn0v.js +1 -0
  174. package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
  175. package/static/app/assets/{Brain-tuhI4sOC.js → Brain-C1HBN0Wf.js} +2 -2
  176. package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
  177. package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
  178. package/static/app/assets/Capture-CFIRsFNE.js +1 -0
  179. package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
  180. package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
  181. package/static/app/assets/Library-DwO3yZST.js +1 -0
  182. package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-Jn1GK0-S.js} +1 -1
  183. package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
  184. package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
  185. package/static/app/assets/System-DW8F-2xL.js +1 -0
  186. package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
  187. package/static/app/assets/{bot-Cia42c2h.js → bot-IM_E_Y12.js} +1 -1
  188. package/static/app/assets/brain-Ci1CkWjM.js +1 -0
  189. package/static/app/assets/{button-2j2Ijzgq.js → button-COwyqfHM.js} +1 -1
  190. package/static/app/assets/circle-check-DfInj-qD.js +1 -0
  191. package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-DEM4A1Y5.js} +1 -1
  192. package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-C9djDuLd.js} +1 -1
  193. package/static/app/assets/{cpu-k4awryFq.js → cpu-DFdo1gw-.js} +1 -1
  194. package/static/app/assets/{download-DFbLJ_ig.js → download-SnJL6oqk.js} +1 -1
  195. package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CqZeDkjE.js} +1 -1
  196. package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-j1jJXYYf.js} +1 -1
  197. package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
  198. package/static/app/assets/index-_u5iUHDr.js +10 -0
  199. package/static/app/assets/input-B0lPdRQZ.js +1 -0
  200. package/static/app/assets/link-2-CoFbooHS.js +1 -0
  201. package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-BsyLxtao.js} +1 -1
  202. package/static/app/assets/primitives-DEbN-d6p.js +1 -0
  203. package/static/app/assets/search-BybIWPNd.js +1 -0
  204. package/static/app/assets/{share-2-BH1M-WNi.js → share-2-CVtZ_ewX.js} +1 -1
  205. package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-CBi2GNWM.js} +1 -1
  206. package/static/app/assets/{textarea-CCWbUfFB.js → textarea-DNMpB5ih.js} +1 -1
  207. package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-C83t3GXF.js} +1 -1
  208. package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
  209. package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-Dcp1OChy.js} +1 -1
  210. package/static/app/assets/utils-BlZr7Pd4.js +4 -0
  211. package/static/app/assets/workspace-jJY4RuAV.js +1 -0
  212. package/static/app/index.html +4 -4
  213. package/static/sw.js +1 -1
  214. package/lattice_brain/graph/_kg_common.py +0 -1331
  215. package/lattice_brain/graph/discovery_index.py +0 -1141
  216. package/lattice_brain/graph/retrieval.py +0 -1120
  217. package/lattice_brain/graph/retrieval_vector.py +0 -1293
  218. package/lattice_brain/ingestion.py +0 -1525
  219. package/lattice_brain/multimodal.py +0 -1258
  220. package/latticeai/core/agent.py +0 -1465
  221. package/latticeai/core/embedding_providers.py +0 -1196
  222. package/latticeai/core/file_generation.py +0 -1047
  223. package/latticeai/integrations/telegram_bot.py +0 -1390
  224. package/latticeai/models/router.py +0 -1007
  225. package/latticeai/runtime/build_phases.py +0 -1450
  226. package/latticeai/services/brain_intelligence.py +0 -1083
  227. package/latticeai/services/memory_service.py +0 -1177
  228. package/latticeai/services/model_runtime.py +0 -1281
  229. package/latticeai/setup/wizard.py +0 -1310
  230. package/static/app/assets/Act-AWf0SAKp.js +0 -1
  231. package/static/app/assets/AdminConsole-D0u8Tiyj.js +0 -1
  232. package/static/app/assets/BrainHome-Ts7G_Ila.js +0 -2
  233. package/static/app/assets/BrainSignals-jMYgQ2Ar.js +0 -1
  234. package/static/app/assets/Capture-CqOSzyPr.js +0 -1
  235. package/static/app/assets/CommandPalette-DC0Bzh-I.js +0 -1
  236. package/static/app/assets/Library-CX-bbhmK.js +0 -1
  237. package/static/app/assets/ProductFlow-BHA2cfKI.js +0 -1
  238. package/static/app/assets/ReviewCard-BUhCKRNM.js +0 -3
  239. package/static/app/assets/System-Bu2t5hn1.js +0 -1
  240. package/static/app/assets/arrow-left-Dzwa5zRb.js +0 -1
  241. package/static/app/assets/brain-DJMoqrwx.js +0 -1
  242. package/static/app/assets/index-BpYkzcVm.js +0 -10
  243. package/static/app/assets/input-DSlJJxRs.js +0 -1
  244. package/static/app/assets/primitives-BCx6TvfG.js +0 -1
  245. package/static/app/assets/search-Cgy8cCFJ.js +0 -1
  246. package/static/app/assets/utils-zqPZJxdx.js +0 -4
  247. package/static/app/assets/workspace-DXTihhfU.js +0 -1
@@ -0,0 +1,42 @@
1
+ """The seam the two portability mixins share.
2
+
3
+ ``KGPortabilityService`` is assembled from :class:`KGPortabilitySharingMixin`
4
+ and :class:`KGPortabilityBackupMixin`. Both read constructor state they do not
5
+ own and both call the availability guard the service defines. That contract
6
+ existed as an unwritten convention; this module writes it down.
7
+
8
+ Typing-only, exactly like :mod:`lattice_brain.graph._kg_contract`: at runtime
9
+ the mixins alias it to ``object``, so the MRO and every method resolution stay
10
+ byte-for-byte what the single-file class had. Adding a cross-mixin call without
11
+ declaring it here is a type error.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ class PortabilityCore:
21
+ """What either portability mixin may assume about ``self``.
22
+
23
+ Never instantiated and never inherited at runtime — see the module
24
+ docstring. Members are declared, not implemented: the implementation lives
25
+ in ``service.py`` (state and availability) or in whichever mixin owns it.
26
+ """
27
+
28
+ # ── State owned by KGPortabilityService.__init__ ─────────────────────────
29
+ _kg: Any
30
+ _data_dir: Path
31
+ _enable: bool
32
+ _exports_dir: Path
33
+ _identity: Any
34
+ _recipient: Any
35
+ _recipient_loaded: bool
36
+
37
+ # ── service.py: the availability guard every write door calls ────────────
38
+ def available(self) -> bool:
39
+ raise NotImplementedError
40
+
41
+ def _require(self) -> None:
42
+ raise NotImplementedError
@@ -0,0 +1,338 @@
1
+ """Binary backup/restore, the encrypted archive, and the storage status reads.
2
+
3
+ The half of :class:`~lattice_brain.portability.KGPortabilityService` that moves
4
+ *files*: a ZIP snapshot of the database plus blobs, the passphrase-encrypted
5
+ ``.latticebrain`` archive, and the status/migration surfaces that sit on top of
6
+ them (a Postgres migration refuses to start without a verified backup).
7
+
8
+ Integrity hashing (``_sha256_file``) and the Postgres migrator are looked up as
9
+ this module's globals, so a test standing in for either patches *this* module.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import tempfile
17
+ import zipfile
18
+ from pathlib import Path
19
+ from typing import Any, Dict, Optional
20
+
21
+ from ..archive import BrainArchivePaths, EncryptedBrainArchive
22
+ from ..storage import (
23
+ DockerPostgresWizard,
24
+ PostgresEngine,
25
+ SQLiteToPostgresMigrator,
26
+ )
27
+ from ..utils import sha256_file as _sha256_file
28
+ from ..utils import utc_now_iso
29
+ from ._contract import PortabilityCore as _Core
30
+ from .constants import BACKUP_FORMAT, FORMAT_VERSION, _stamp
31
+ from .fsops import (
32
+ _pre_restore_backup_dir,
33
+ _replace_sqlite_atomically,
34
+ _replace_tree_with_backup,
35
+ _rollback_sqlite_from_backup,
36
+ _safe_zip_names,
37
+ )
38
+
39
+
40
+ class KGPortabilityBackupMixin(_Core):
41
+ """Backup / restore / storage status. Mixed into ``KGPortabilityService``."""
42
+
43
+ # ── binary backup / restore ──────────────────────────────────────────────
44
+ def backup(self, dest_path=None) -> Dict[str, Any]:
45
+ self._require()
46
+ self._exports_dir.mkdir(parents=True, exist_ok=True)
47
+ dest = Path(dest_path) if dest_path else self._exports_dir / f"kg-backup-{_stamp()}.zip"
48
+ with tempfile.TemporaryDirectory() as tmp_s:
49
+ tmp = Path(tmp_s)
50
+ db_copy = tmp / "knowledge_graph.sqlite"
51
+ self._kg.backup_database(db_copy)
52
+ manifest = {
53
+ "format": BACKUP_FORMAT,
54
+ "format_version": FORMAT_VERSION,
55
+ **self._kg.schema_versions(),
56
+ "created_at": utc_now_iso(),
57
+ "db_sha256": _sha256_file(db_copy),
58
+ "has_blobs": Path(self._kg.blob_dir).exists(),
59
+ }
60
+ with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as zf:
61
+ zf.write(db_copy, "knowledge_graph.sqlite")
62
+ zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
63
+ blob_dir = Path(self._kg.blob_dir)
64
+ if blob_dir.exists():
65
+ for f in blob_dir.rglob("*"):
66
+ if f.is_file():
67
+ zf.write(f, f"blobs/{f.relative_to(blob_dir)}")
68
+ return {"path": str(dest), "bytes": dest.stat().st_size, "manifest": manifest}
69
+
70
+ def restore(
71
+ self,
72
+ archive_path,
73
+ *,
74
+ verify: bool = True,
75
+ dry_run: bool = False,
76
+ confirm: bool = False,
77
+ ) -> Dict[str, Any]:
78
+ self._require()
79
+ archive = Path(archive_path)
80
+ if not archive.exists():
81
+ raise FileNotFoundError(f"Backup archive not found: {archive}")
82
+ if not dry_run and not confirm:
83
+ raise ValueError("Explicit confirmation is required before restoring a Knowledge Graph backup.")
84
+ with zipfile.ZipFile(archive) as zf:
85
+ names = zf.namelist()
86
+ _safe_zip_names(names)
87
+ if "knowledge_graph.sqlite" not in names:
88
+ raise ValueError("Archive is missing knowledge_graph.sqlite.")
89
+ manifest = json.loads(zf.read("manifest.json")) if "manifest.json" in names else {}
90
+ with tempfile.TemporaryDirectory() as tmp_s:
91
+ tmp = Path(tmp_s)
92
+ zf.extractall(tmp)
93
+ db_src = tmp / "knowledge_graph.sqlite"
94
+ if verify and manifest.get("db_sha256"):
95
+ if _sha256_file(db_src) != manifest["db_sha256"]:
96
+ raise ValueError("Backup integrity check failed (db sha256 mismatch).")
97
+ if dry_run:
98
+ return {
99
+ "restored": False,
100
+ "dry_run": True,
101
+ "verified": True,
102
+ "manifest": manifest,
103
+ "planned": {
104
+ "database": str(self._kg.db_path),
105
+ "blobs": str(self._kg.blob_dir),
106
+ "archive": str(archive),
107
+ },
108
+ }
109
+ db_dest = Path(self._kg.db_path)
110
+ blob_dest = Path(self._kg.blob_dir)
111
+ backup_dir = _pre_restore_backup_dir(db_dest)
112
+ try:
113
+ _replace_sqlite_atomically(db_src, db_dest, backup_dir)
114
+ blob_src = tmp / "blobs"
115
+ _replace_tree_with_backup(blob_src if blob_src.exists() else None, blob_dest, backup_dir)
116
+ except Exception:
117
+ # The rollback is best-effort recovery; an I/O slip inside
118
+ # it must never mask the restore failure being reported.
119
+ try:
120
+ _rollback_sqlite_from_backup(db_dest, backup_dir)
121
+ except OSError as rollback_exc:
122
+ logging.warning(
123
+ "pre-restore rollback incomplete (backup kept at %s): %s",
124
+ backup_dir, rollback_exc,
125
+ )
126
+ raise
127
+ stats = self._kg.stats()
128
+ return {
129
+ "restored": True,
130
+ "manifest": manifest,
131
+ "pre_restore_backup": str(backup_dir),
132
+ "nodes": sum(stats.get("nodes", {}).values()),
133
+ }
134
+
135
+ def verify_backup(self, archive_path) -> Dict[str, Any]:
136
+ archive = Path(archive_path)
137
+ if not archive.exists():
138
+ return {"ok": False, "path": str(archive), "errors": [f"Backup archive not found: {archive}"]}
139
+ try:
140
+ with zipfile.ZipFile(archive) as zf:
141
+ names = zf.namelist()
142
+ _safe_zip_names(names)
143
+ if "knowledge_graph.sqlite" not in names:
144
+ raise ValueError("Archive is missing knowledge_graph.sqlite.")
145
+ manifest = json.loads(zf.read("manifest.json")) if "manifest.json" in names else {}
146
+ with tempfile.TemporaryDirectory() as tmp_s:
147
+ tmp = Path(tmp_s)
148
+ zf.extract("knowledge_graph.sqlite", tmp)
149
+ db_src = tmp / "knowledge_graph.sqlite"
150
+ if manifest.get("db_sha256") and _sha256_file(db_src) != manifest["db_sha256"]:
151
+ raise ValueError("Backup integrity check failed (db sha256 mismatch).")
152
+ return {"ok": True, "path": str(archive), "manifest": manifest, "errors": []}
153
+ except (ValueError, zipfile.BadZipFile, OSError, json.JSONDecodeError) as exc:
154
+ return {"ok": False, "path": str(archive), "errors": [str(exc)]}
155
+
156
+ # ── encrypted .latticebrain archive ───────────────────────────────────
157
+ def encrypted_archive(self, dest_path=None, *, passphrase: str) -> Dict[str, Any]:
158
+ self._require()
159
+ self._exports_dir.mkdir(parents=True, exist_ok=True)
160
+ dest = Path(dest_path) if dest_path else self._exports_dir / f"brain-{_stamp()}.latticebrain"
161
+ metadata = {
162
+ "storage": self.storage_status().get("active", {}),
163
+ "snapshot": self.snapshot_metadata(),
164
+ "device_identity": self._identity.describe() if self._identity is not None else {},
165
+ "provenance": {"exported_at": utc_now_iso(), "source": "kg-portability"},
166
+ }
167
+ archive = EncryptedBrainArchive(
168
+ BrainArchivePaths(
169
+ db_path=Path(self._kg.db_path),
170
+ blob_dir=Path(self._kg.blob_dir),
171
+ data_dir=self._data_dir,
172
+ metadata=metadata,
173
+ )
174
+ )
175
+ return archive.create(dest, passphrase=passphrase)
176
+
177
+ def inspect_encrypted_archive(self, archive_path, *, passphrase: Optional[str] = None) -> Dict[str, Any]:
178
+ archive = EncryptedBrainArchive(
179
+ BrainArchivePaths(
180
+ db_path=Path(self._kg.db_path),
181
+ blob_dir=Path(self._kg.blob_dir),
182
+ data_dir=self._data_dir,
183
+ )
184
+ )
185
+ return archive.inspect(Path(archive_path), passphrase=passphrase)
186
+
187
+ def verify_encrypted_archive(self, archive_path, *, passphrase: str) -> Dict[str, Any]:
188
+ archive = EncryptedBrainArchive(
189
+ BrainArchivePaths(
190
+ db_path=Path(self._kg.db_path),
191
+ blob_dir=Path(self._kg.blob_dir),
192
+ data_dir=self._data_dir,
193
+ )
194
+ )
195
+ return archive.verify(Path(archive_path), passphrase=passphrase)
196
+
197
+ def restore_encrypted_archive(
198
+ self,
199
+ archive_path,
200
+ *,
201
+ passphrase: str,
202
+ dry_run: bool = False,
203
+ confirm: bool = False,
204
+ ) -> Dict[str, Any]:
205
+ self._require()
206
+ archive = EncryptedBrainArchive(
207
+ BrainArchivePaths(
208
+ db_path=Path(self._kg.db_path),
209
+ blob_dir=Path(self._kg.blob_dir),
210
+ data_dir=self._data_dir,
211
+ )
212
+ )
213
+ return archive.restore(
214
+ Path(archive_path),
215
+ passphrase=passphrase,
216
+ target=BrainArchivePaths(
217
+ db_path=Path(self._kg.db_path),
218
+ blob_dir=Path(self._kg.blob_dir),
219
+ data_dir=self._data_dir,
220
+ ),
221
+ dry_run=dry_run,
222
+ confirm=confirm,
223
+ )
224
+
225
+ def import_encrypted_archive(
226
+ self,
227
+ archive_path,
228
+ *,
229
+ passphrase: str,
230
+ dry_run: bool = False,
231
+ confirm: bool = False,
232
+ ) -> Dict[str, Any]:
233
+ result = self.restore_encrypted_archive(
234
+ archive_path,
235
+ passphrase=passphrase,
236
+ dry_run=dry_run,
237
+ confirm=confirm,
238
+ )
239
+ result["operation"] = "import"
240
+ return result
241
+
242
+ # ── status surface ───────────────────────────────────────────────────────
243
+ def snapshot_metadata(self) -> Dict[str, Any]:
244
+ if not self.available():
245
+ return {"available": False}
246
+ return {
247
+ "available": True,
248
+ **self._kg.schema_versions(),
249
+ "stats": self._kg.stats(),
250
+ "provenance": self._kg.provenance_stats(),
251
+ "storage": (
252
+ self._kg.storage_engine.capabilities().as_dict()
253
+ if getattr(self._kg, "storage_engine", None) is not None
254
+ else {"engine": "sqlite", "available": True}
255
+ ),
256
+ }
257
+
258
+ def storage_status(self) -> Dict[str, Any]:
259
+ if not self.available():
260
+ return {"available": False}
261
+ return {
262
+ "available": True,
263
+ "active": (
264
+ self._kg.storage_engine.capabilities().as_dict()
265
+ if getattr(self._kg, "storage_engine", None) is not None
266
+ else {"engine": "sqlite", "available": True}
267
+ ),
268
+ "postgres": PostgresEngine("", schema="lattice_brain").capabilities().as_dict(),
269
+ "backup_health": self.backup_health(),
270
+ }
271
+
272
+ def backup_health(self) -> Dict[str, Any]:
273
+ self._exports_dir.mkdir(parents=True, exist_ok=True)
274
+ backups = sorted(
275
+ [
276
+ p for p in self._exports_dir.glob("*")
277
+ if p.is_file() and (p.suffix == ".zip" or p.suffix == ".latticebrain")
278
+ ],
279
+ key=lambda p: p.stat().st_mtime,
280
+ reverse=True,
281
+ )
282
+ latest = backups[0] if backups else None
283
+ return {
284
+ "available": True,
285
+ "directory": str(self._exports_dir),
286
+ "count": len(backups),
287
+ "latest": str(latest) if latest else None,
288
+ "latest_bytes": latest.stat().st_size if latest else 0,
289
+ "encrypted_archives": sum(1 for p in backups if p.suffix == ".latticebrain"),
290
+ "zip_backups": sum(1 for p in backups if p.suffix == ".zip"),
291
+ }
292
+
293
+ def postgres_docker_setup(
294
+ self,
295
+ *,
296
+ consent: bool,
297
+ dry_run: bool = False,
298
+ port: int = 5432,
299
+ ) -> Dict[str, Any]:
300
+ wizard = DockerPostgresWizard(self._data_dir / "postgres", port=port)
301
+ return wizard.start(consent=consent, dry_run=dry_run)
302
+
303
+ def migrate_sqlite_to_postgres(
304
+ self,
305
+ *,
306
+ dsn: str,
307
+ schema: str = "lattice_brain",
308
+ dry_run: bool = True,
309
+ ) -> Dict[str, Any]:
310
+ self._require()
311
+ if not dsn:
312
+ raise ValueError("Postgres DSN is required for SQLite to Postgres migration.")
313
+ migrator = SQLiteToPostgresMigrator(
314
+ Path(self._kg.db_path),
315
+ PostgresEngine(dsn, schema=schema),
316
+ )
317
+ if dry_run:
318
+ return migrator.migrate(dry_run=True)
319
+ backup = self.backup()
320
+ verification = self.verify_backup(backup["path"])
321
+ if not verification.get("ok"):
322
+ raise RuntimeError(
323
+ "Pre-migration backup verification failed; Postgres migration was not started: "
324
+ + "; ".join(verification.get("errors") or [])
325
+ )
326
+ result = migrator.migrate(dry_run=False)
327
+ result["pre_migration_backup"] = {
328
+ "path": backup["path"],
329
+ "verified": True,
330
+ "manifest": backup.get("manifest"),
331
+ }
332
+ return result
333
+
334
+ def recent_ingestions(self, *, limit: int = 50, source_type: Optional[str] = None) -> Dict[str, Any]:
335
+ """Recent provenance records (newest first) for the ingestion-sources UI."""
336
+ if not self.available():
337
+ return {"items": [], "count": 0}
338
+ return self._kg.list_provenance(limit=limit, source_type=source_type)
@@ -0,0 +1,136 @@
1
+ """Pure shaping of a shared subgraph bundle: select, expand, redact, digest.
2
+
3
+ None of these touch a store or a file. They decide *what* travels — which
4
+ nodes the selection names, which neighbours a one-hop expansion may admit, and
5
+ which sender-identifying fields are stripped on the way out — and they pin the
6
+ result with the digest the signature covers.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from typing import Any, Dict, List, Optional, Set
14
+
15
+ from .constants import _NEIGHBOR_EXCLUDED_TYPES, _REDACTED_FIELDS
16
+
17
+
18
+ def _canonical_digest(payload: Dict[str, Any]) -> str:
19
+ """sha256 over the canonical JSON of a bundle payload.
20
+
21
+ Pinned in the signed header so the Ed25519 signature covers the contents,
22
+ not just the manifest describing them.
23
+ """
24
+ canonical = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
25
+ return hashlib.sha256(canonical).hexdigest()
26
+
27
+
28
+ def _node_source_type(node: Dict[str, Any]) -> str:
29
+ try:
30
+ metadata = json.loads(node.get("metadata_json") or "{}")
31
+ except (TypeError, ValueError):
32
+ return ""
33
+ if not isinstance(metadata, dict):
34
+ return ""
35
+ return str(metadata.get("source_type") or "").strip().lower()
36
+
37
+
38
+ def _select_node_ids(nodes: List[Dict[str, Any]], selection: Dict[str, Any]) -> Set[str]:
39
+ wanted_ids = set(selection["node_ids"])
40
+ wanted_types = set(selection["node_types"])
41
+ wanted_sources = set(selection["source_types"])
42
+ keep: Set[str] = set()
43
+ for node in nodes:
44
+ node_id = str(node.get("id"))
45
+ if node_id in wanted_ids:
46
+ keep.add(node_id)
47
+ continue
48
+ if wanted_types and str(node.get("type") or "").strip().lower() in wanted_types:
49
+ keep.add(node_id)
50
+ continue
51
+ if wanted_sources and _node_source_type(node) in wanted_sources:
52
+ keep.add(node_id)
53
+ return keep
54
+
55
+
56
+ def _expand_neighbors(
57
+ keep: Set[str], edges: List[Dict[str, Any]], known: Dict[str, str]
58
+ ) -> Set[str]:
59
+ """One hop out from the selection, in both directions, within the scope.
60
+
61
+ ``Person`` and ``Source`` neighbours are never pulled in implicitly: a
62
+ Person node's label *is* an email address and a Source node's summary *is*
63
+ a local file path, so "share this decision and what it touches" would
64
+ quietly ship the sender's identity and directory layout. Naming one of
65
+ those node ids explicitly still exports it — that is a decision, not a
66
+ side effect.
67
+ """
68
+ expanded = set(keep)
69
+
70
+ def _admit(candidate: str) -> None:
71
+ if candidate in known and known[candidate] not in _NEIGHBOR_EXCLUDED_TYPES:
72
+ expanded.add(candidate)
73
+
74
+ for edge in edges:
75
+ source = str(edge.get("from_node"))
76
+ target = str(edge.get("to_node"))
77
+ if source in keep:
78
+ _admit(target)
79
+ if target in keep:
80
+ _admit(source)
81
+ return expanded
82
+
83
+
84
+ def _strip_fields(raw_json: Optional[str]) -> Optional[str]:
85
+ if not raw_json:
86
+ return raw_json
87
+ try:
88
+ parsed = json.loads(raw_json)
89
+ except (TypeError, ValueError):
90
+ return raw_json
91
+ if not isinstance(parsed, dict):
92
+ return raw_json
93
+ cleaned = {k: v for k, v in parsed.items() if k not in _REDACTED_FIELDS}
94
+ return json.dumps(cleaned, ensure_ascii=False, sort_keys=True)
95
+
96
+
97
+ def _redact_node(node: Dict[str, Any]) -> Dict[str, Any]:
98
+ redacted = dict(node)
99
+ redacted["metadata_json"] = _strip_fields(node.get("metadata_json"))
100
+ redacted["raw_json"] = _strip_fields(node.get("raw_json"))
101
+ return redacted
102
+
103
+
104
+ def _redact_provenance_row(row: Dict[str, Any]) -> Dict[str, Any]:
105
+ redacted = dict(row)
106
+ redacted["owner"] = None
107
+ redacted["source_uri"] = None
108
+ redacted["permissions_json"] = "{}"
109
+ redacted["metadata_json"] = _strip_fields(row.get("metadata_json"))
110
+ return redacted
111
+
112
+
113
+ def _scope_node(node: Dict[str, Any], workspace_id: str) -> Dict[str, Any]:
114
+ """Stamp the accepting workspace onto an incoming node.
115
+
116
+ Received knowledge belongs to the workspace that accepted it. Without this
117
+ the projection would file it as legacy-global — machine-shared — which is a
118
+ wider scope than the reviewer chose.
119
+ """
120
+ scoped = dict(node)
121
+ try:
122
+ metadata = json.loads(scoped.get("metadata_json") or "{}")
123
+ except (TypeError, ValueError):
124
+ metadata = {}
125
+ if not isinstance(metadata, dict):
126
+ metadata = {}
127
+ metadata["workspace_id"] = workspace_id
128
+ scoped["metadata_json"] = json.dumps(metadata, ensure_ascii=False, sort_keys=True)
129
+ return scoped
130
+
131
+
132
+ def _shared_node_summary(node: Dict[str, Any], verdict: Dict[str, Any]) -> str:
133
+ body = str(node.get("summary") or "").strip()
134
+ origin = verdict.get("origin") or "unknown device"
135
+ prefix = f"[{origin}] "
136
+ return (prefix + body)[:500] if body else prefix.strip()
@@ -0,0 +1,93 @@
1
+ """Formats, gates and the vocabulary the portability surfaces agree on.
2
+
3
+ Everything here is a literal or a gate: no I/O, no graph, no service. Keeping
4
+ it in one leaf module is what lets the file helpers, the sharing surface and
5
+ the backup surface all import the same constants without importing each other.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ..gates import FeatureGate
11
+ from ..utils import utc_now_iso
12
+
13
+ FORMAT = "latticeai.kg.export"
14
+ FORMAT_VERSION = 1
15
+ BACKUP_FORMAT = "latticeai.kg.backup"
16
+
17
+ # ── selective subgraph share (v11.1.0 prototype, off by default) ─────────────
18
+ #: Logical format of a *partial* bundle: a chosen slice of the graph plus its
19
+ #: provenance, signed by this device. Deliberately distinct from ``FORMAT`` so
20
+ #: the whole-graph importer can never be handed a subgraph by accident.
21
+ SUBGRAPH_FORMAT = "latticeai.kg.subgraph"
22
+ SUBGRAPH_FORMAT_VERSION = 1
23
+ #: Encrypted on-disk envelope for the same bundle, reusing the ``.latticebrain``
24
+ #: passphrase mechanism (PBKDF2-SHA256 + AES-256-GCM) from ``archive.py``.
25
+ SUBGRAPH_ARCHIVE_FORMAT = "latticebrain.subgraph"
26
+ #: Opt-in gate. Off unless the operator sets it: sharing knowledge with another
27
+ #: Brain is a network act, and local-first means those are never on by default.
28
+ BRAIN_NETWORK_ENV = "LATTICEAI_BRAIN_NETWORK"
29
+ _TRUTHY = frozenset({"1", "true", "yes", "on"})
30
+ #: How a bundle can be encrypted. ``passphrase`` needs a secret agreed out of
31
+ #: band; ``recipient_public_key`` needs nothing secret to travel at all.
32
+ ENCRYPTION_MODES = ("passphrase", "recipient_public_key")
33
+ #: A received bundle becomes review items, never a merge. The cap keeps one
34
+ #: peer from flooding the inbox; what it dropped is reported, not hidden.
35
+ SUBGRAPH_PROPOSAL_CAP = 200
36
+ #: Review-queue vocabulary. ``kg_change_digest`` is the existing source for
37
+ #: "the graph would change"; the kind distinguishes shared-subgraph items.
38
+ SUBGRAPH_REVIEW_SOURCE = "kg_change_digest"
39
+ SUBGRAPH_REVIEW_KIND = "shared_subgraph_node"
40
+ #: Mirrors ``services.review_queue.OPEN_STATUSES``. Duplicated as a literal
41
+ #: rather than imported because ``lattice_brain`` must not depend on the
42
+ #: ``latticeai`` service layer; the review sink is injected, not imported.
43
+ _OPEN_REVIEW_STATUSES = frozenset({"pending", "snoozed"})
44
+
45
+ BRAIN_NETWORK_DISABLED_DETAIL = (
46
+ "Brain Network sharing is off. It is opt-in by design: set "
47
+ f"{BRAIN_NETWORK_ENV}=1 to enable selective subgraph export and receipt."
48
+ )
49
+
50
+
51
+ #: The share gate, resolved when it is asked. The environment variable is still
52
+ #: the default answer — an untouched install behaves exactly as it did — but a
53
+ #: settings surface can now bind a resolver and move it without a restart.
54
+ BRAIN_NETWORK_GATE = FeatureGate(
55
+ BRAIN_NETWORK_ENV,
56
+ default=False,
57
+ name="brain_network",
58
+ detail=BRAIN_NETWORK_DISABLED_DETAIL,
59
+ )
60
+
61
+
62
+ class BrainNetworkDisabled(PermissionError):
63
+ """Raised when a share surface is used while the opt-in flag is off."""
64
+
65
+ def __init__(self, detail: str = BRAIN_NETWORK_DISABLED_DETAIL) -> None:
66
+ super().__init__(detail)
67
+
68
+
69
+ def brain_network_enabled() -> bool:
70
+ """True only when the operator explicitly opted in (default: off)."""
71
+ return BRAIN_NETWORK_GATE.enabled()
72
+
73
+
74
+ def require_brain_network() -> None:
75
+ if not brain_network_enabled():
76
+ raise BrainNetworkDisabled()
77
+
78
+
79
+ def _stamp() -> str:
80
+ return utc_now_iso().replace(":", "").replace("-", "").replace(".", "")[:15]
81
+
82
+
83
+ #: Sender-identifying fields stripped from a shared bundle by default. The
84
+ #: knowledge travels; "which account, on which machine, at which path" does not.
85
+ _REDACTED_FIELDS = frozenset({"owner", "user_email", "source_uri", "permissions"})
86
+ #: The signed payload's key set. One tuple so export and verify can never
87
+ #: disagree about what the digest covers.
88
+ _PAYLOAD_KEYS = (
89
+ "nodes", "chunk_nodes", "edges", "chunks", "knowledge_sources", "provenance",
90
+ )
91
+ #: Node types a one-hop expansion never admits — they describe the sender
92
+ #: (identity, local paths), not the knowledge being shared.
93
+ _NEIGHBOR_EXCLUDED_TYPES = frozenset({"Person", "Source"})