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
@@ -275,10 +275,44 @@ function NetworkPanel() {
275
275
  }
276
276
 
277
277
  function SettingsPanel() {
278
+ const qc = useQueryClient();
278
279
  const { theme, setTheme, mode, setMode } = useAppStore();
279
280
  const health = useQuery({ queryKey: ["health"], queryFn: latticeApi.health });
280
281
  const sys = useQuery({ queryKey: ["sysinfo"], queryFn: latticeApi.sysinfo });
281
282
  const comp = useQuery({ queryKey: ["computerMemory"], queryFn: latticeApi.computerMemory });
283
+ const storage = useQuery({ queryKey: ["brainStorage"], queryFn: latticeApi.brainStorage });
284
+ const backupHealth = useQuery({ queryKey: ["backupHealth"], queryFn: latticeApi.backupHealth });
285
+ const [dsn, setDsn] = React.useState("");
286
+ const [schema, setSchema] = React.useState("lattice_brain");
287
+ const [dockerConsent, setDockerConsent] = React.useState(false);
288
+ const [archivePath, setArchivePath] = React.useState("");
289
+ const [restorePath, setRestorePath] = React.useState("");
290
+ const [archivePassphrase, setArchivePassphrase] = React.useState("");
291
+ const [restoreConfirm, setRestoreConfirm] = React.useState(false);
292
+ const docker = useMutation({ mutationFn: (consent: boolean) => latticeApi.dockerPostgres({ consent, dry_run: !consent, port: 5432 }) });
293
+ const migration = useMutation({
294
+ mutationFn: () => latticeApi.migratePostgres({ dsn, schema_name: schema || "lattice_brain", dry_run: true }),
295
+ });
296
+ const archiveCreate = useMutation({
297
+ mutationFn: () => latticeApi.brainArchive({ path: archivePath.trim() || null, passphrase: archivePassphrase }),
298
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["backupHealth"] }),
299
+ });
300
+ const archiveInspect = useMutation({
301
+ mutationFn: () => latticeApi.brainArchiveInspect({ path: restorePath, passphrase: archivePassphrase || null }),
302
+ });
303
+ const archiveVerify = useMutation({
304
+ mutationFn: () => latticeApi.brainArchiveVerify({ path: restorePath, passphrase: archivePassphrase }),
305
+ });
306
+ const archiveDryRun = useMutation({
307
+ mutationFn: () => latticeApi.brainArchiveRestore({ path: restorePath, passphrase: archivePassphrase, dry_run: true, confirm: false }),
308
+ });
309
+ const archiveRestore = useMutation({
310
+ mutationFn: () => latticeApi.brainArchiveRestore({ path: restorePath, passphrase: archivePassphrase, dry_run: false, confirm: restoreConfirm }),
311
+ onSuccess: () => {
312
+ qc.invalidateQueries({ queryKey: ["brainStorage"] });
313
+ qc.invalidateQueries({ queryKey: ["backupHealth"] });
314
+ },
315
+ });
282
316
  return (
283
317
  <div className="grid gap-4 xl:grid-cols-3">
284
318
  <Card>
@@ -300,6 +334,62 @@ function SettingsPanel() {
300
334
  <DataPanel title="Host telemetry" result={sys.data}>
301
335
  {(data) => <JsonView value={data} />}
302
336
  </DataPanel>
337
+ <DataPanel title="Brain storage" result={storage.data} className="xl:col-span-3">
338
+ {(data) => <JsonView value={data} />}
339
+ </DataPanel>
340
+ <DataPanel title="Backup health" result={backupHealth.data} className="xl:col-span-3">
341
+ {(data) => <JsonView value={data} />}
342
+ </DataPanel>
343
+ <Card className="xl:col-span-3">
344
+ <CardHeader>
345
+ <CardTitle>.latticebrain portability</CardTitle>
346
+ <CardDescription>Encrypted export, inspect, verify, dry-run restore, and confirmed restore use the Brain portability API.</CardDescription>
347
+ </CardHeader>
348
+ <CardContent className="grid gap-3">
349
+ <div className="grid gap-2 sm:grid-cols-[1fr_1fr]">
350
+ <Input value={archivePath} onChange={(e) => setArchivePath(e.target.value)} placeholder="export path (optional)" />
351
+ <Input value={restorePath} onChange={(e) => setRestorePath(e.target.value)} placeholder="archive path for inspect/restore" />
352
+ </div>
353
+ <Input type="password" value={archivePassphrase} onChange={(e) => setArchivePassphrase(e.target.value)} placeholder="archive passphrase" />
354
+ <div className="flex flex-wrap gap-2">
355
+ <Button onClick={() => archiveCreate.mutate()} disabled={!archivePassphrase || archiveCreate.isPending}>Export archive</Button>
356
+ <Button variant="outline" onClick={() => archiveInspect.mutate()} disabled={!restorePath || archiveInspect.isPending}>Inspect</Button>
357
+ <Button variant="outline" onClick={() => archiveVerify.mutate()} disabled={!restorePath || !archivePassphrase || archiveVerify.isPending}>Verify</Button>
358
+ <Button variant="outline" onClick={() => archiveDryRun.mutate()} disabled={!restorePath || !archivePassphrase || archiveDryRun.isPending}>Restore dry run</Button>
359
+ <label className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
360
+ <input type="checkbox" checked={restoreConfirm} onChange={(e) => setRestoreConfirm(e.target.checked)} />
361
+ Confirm restore
362
+ </label>
363
+ <Button variant="destructive" onClick={() => archiveRestore.mutate()} disabled={!restorePath || !archivePassphrase || !restoreConfirm || archiveRestore.isPending}>Restore</Button>
364
+ </div>
365
+ {[archiveCreate.data, archiveInspect.data, archiveVerify.data, archiveDryRun.data, archiveRestore.data].filter(Boolean).map((item, i) => (
366
+ <JsonView key={i} value={item?.data || item?.error} />
367
+ ))}
368
+ </CardContent>
369
+ </Card>
370
+ <Card className="xl:col-span-3">
371
+ <CardHeader>
372
+ <CardTitle>Postgres scale mode</CardTitle>
373
+ <CardDescription>Opt-in migration and Docker setup; SQLite remains the default.</CardDescription>
374
+ </CardHeader>
375
+ <CardContent className="grid gap-3">
376
+ <div className="grid gap-2 sm:grid-cols-[1fr_220px]">
377
+ <Input value={dsn} onChange={(e) => setDsn(e.target.value)} placeholder="postgresql://user:pass@127.0.0.1:5432/lattice_brain" />
378
+ <Input value={schema} onChange={(e) => setSchema(e.target.value)} placeholder="schema" />
379
+ </div>
380
+ <div className="flex flex-wrap gap-2">
381
+ <Button variant="outline" onClick={() => docker.mutate(false)} disabled={docker.isPending}>Docker plan</Button>
382
+ <label className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
383
+ <input type="checkbox" checked={dockerConsent} onChange={(e) => setDockerConsent(e.target.checked)} />
384
+ Consent to start Docker
385
+ </label>
386
+ <Button onClick={() => docker.mutate(true)} disabled={!dockerConsent || docker.isPending}>Start Docker</Button>
387
+ <Button variant="outline" onClick={() => migration.mutate()} disabled={!dsn || migration.isPending}>Plan migration</Button>
388
+ </div>
389
+ {docker.data ? <JsonView value={docker.data.data || docker.data.error} /> : null}
390
+ {migration.data ? <JsonView value={migration.data.data || migration.data.error} /> : null}
391
+ </CardContent>
392
+ </Card>
303
393
  <DataPanel title="Computer memory" result={comp.data} className="xl:col-span-3">
304
394
  {(data) => (
305
395
  <div className="space-y-3">
@@ -321,6 +411,7 @@ function AdminPanel() {
321
411
  const audit = useQuery({ queryKey: ["adminAudit"], queryFn: latticeApi.adminAudit });
322
412
  const roles = useQuery({ queryKey: ["adminRoles"], queryFn: latticeApi.adminRoles });
323
413
  const policies = useQuery({ queryKey: ["adminPolicies"], queryFn: latticeApi.adminPolicies });
414
+ const hardening = useQuery({ queryKey: ["adminProductHardening"], queryFn: latticeApi.adminProductHardening });
324
415
  const security = useQuery({ queryKey: ["adminSecurity"], queryFn: latticeApi.adminSecurity });
325
416
  const vpc = useQuery({ queryKey: ["vpcStatus"], queryFn: latticeApi.vpcStatus });
326
417
  return (
@@ -330,6 +421,7 @@ function AdminPanel() {
330
421
  <DataPanel title="Audit" result={audit.data}>{(data) => <EntityList items={(data as Record<string, unknown>).recent_events || data} titleKey="act" metaKey="sev" />}</DataPanel>
331
422
  <DataPanel title="Roles" result={roles.data}>{(data) => <JsonView value={data} />}</DataPanel>
332
423
  <DataPanel title="Policies" result={policies.data}>{(data) => <JsonView value={data} />}</DataPanel>
424
+ <DataPanel title="Product hardening" result={hardening.data}>{(data) => <JsonView value={data} />}</DataPanel>
333
425
  <DataPanel title="Security overview" result={security.data}>{(data) => <JsonView value={data} />}</DataPanel>
334
426
  <DataPanel title="Private VPC" result={vpc.data} className="xl:col-span-2">
335
427
  {(data) => (
package/kg_schema.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Compatibility shim for the v4 Knowledge Graph schema."""
2
2
 
3
- from latticeai.brain.schema import * # noqa: F403,F401
3
+ from lattice_brain.schema import * # noqa: F403,F401
@@ -1,10 +1,10 @@
1
- """Compatibility shim for the v4 brain store.
1
+ """Compatibility shim for the v4.2 lattice-brain store.
2
2
 
3
- The implementation now lives under :mod:`latticeai.brain`. Root imports are
3
+ The implementation now lives under :mod:`lattice_brain`. Root imports are
4
4
  kept for older integrations and tests.
5
5
  """
6
6
 
7
- from latticeai.brain._kg_common import ( # noqa: F401
7
+ from lattice_brain._kg_common import ( # noqa: F401
8
8
  EDGE_VERB,
9
9
  GRAPH_SCHEMA_VERSION,
10
10
  LOCAL_CODE_EXTENSIONS,
@@ -24,7 +24,7 @@ from latticeai.brain._kg_common import ( # noqa: F401
24
24
  _slug,
25
25
  set_llm_router,
26
26
  )
27
- from latticeai.brain.store import KnowledgeGraphStore
27
+ from lattice_brain.store import KnowledgeGraphStore
28
28
 
29
29
  __all__ = [
30
30
  "KnowledgeGraphStore",
@@ -0,0 +1,70 @@
1
+ """lattice-brain — independent Brain Core package for Lattice AI.
2
+
3
+ Heavy graph modules are lazy-loaded so storage and archive utilities remain
4
+ usable without importing the FastAPI application or creating runtime globals.
5
+ """
6
+
7
+ from .archive import BrainArchivePaths, EncryptedBrainArchive
8
+ from .core import BrainCore, BrainCoreConfig
9
+ from .storage import (
10
+ DockerPostgresPlan,
11
+ DockerPostgresWizard,
12
+ PostgresConfig,
13
+ PostgresEngine,
14
+ SQLiteEngine,
15
+ SQLiteToPostgresMigrator,
16
+ StorageCapabilities,
17
+ StorageEngine,
18
+ StorageUnavailable,
19
+ storage_from_env,
20
+ )
21
+
22
+ __version__ = "4.3.0"
23
+
24
+ __all__ = [
25
+ "AssembledContext",
26
+ "BrainArchivePaths",
27
+ "BrainCore",
28
+ "BrainCoreConfig",
29
+ "BrainMemory",
30
+ "ContextAssembler",
31
+ "ContextSection",
32
+ "ConversationStore",
33
+ "DockerPostgresPlan",
34
+ "DockerPostgresWizard",
35
+ "EncryptedBrainArchive",
36
+ "KnowledgeGraphStore",
37
+ "PostgresConfig",
38
+ "PostgresEngine",
39
+ "SQLiteEngine",
40
+ "SQLiteToPostgresMigrator",
41
+ "StorageCapabilities",
42
+ "StorageEngine",
43
+ "StorageUnavailable",
44
+ "storage_from_env",
45
+ "__version__",
46
+ ]
47
+
48
+
49
+ def __getattr__(name: str):
50
+ if name in {"AssembledContext", "ContextAssembler", "ContextSection"}:
51
+ from .context import AssembledContext, ContextAssembler, ContextSection
52
+
53
+ return {
54
+ "AssembledContext": AssembledContext,
55
+ "ContextAssembler": ContextAssembler,
56
+ "ContextSection": ContextSection,
57
+ }[name]
58
+ if name == "ConversationStore":
59
+ from .conversations import ConversationStore
60
+
61
+ return ConversationStore
62
+ if name == "BrainMemory":
63
+ from .memory import BrainMemory
64
+
65
+ return BrainMemory
66
+ if name == "KnowledgeGraphStore":
67
+ from .store import KnowledgeGraphStore
68
+
69
+ return KnowledgeGraphStore
70
+ raise AttributeError(name)
@@ -0,0 +1 @@
1
+ from latticeai.brain._kg_common import * # noqa: F401,F403
@@ -0,0 +1,446 @@
1
+ """Encrypted .latticebrain archive support.
2
+
3
+ The archive is intentionally self-contained and local-only: the encrypted
4
+ payload holds the SQLite brain, blob store, portable JSON state, signed export
5
+ bundles, and public metadata needed to inspect/verify/restore on another
6
+ machine without contacting a service.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import io
14
+ import json
15
+ import os
16
+ import shutil
17
+ import tempfile
18
+ import zipfile
19
+ from dataclasses import dataclass
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path, PurePosixPath
22
+ from typing import Any, Dict, Iterable, List, Optional
23
+
24
+ from cryptography.hazmat.primitives import hashes
25
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
26
+ from cryptography.exceptions import InvalidTag
27
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
28
+
29
+
30
+ ARCHIVE_FORMAT = "latticebrain.encrypted"
31
+ ARCHIVE_VERSION = 2
32
+ KDF_ITERATIONS = 390_000
33
+ PORTABLE_DATA_FILES = (
34
+ "users.json",
35
+ "chat_history.json",
36
+ "workspace_os.json",
37
+ "vpc_config.json",
38
+ "mcp_installs.json",
39
+ "audit_log.json",
40
+ "sso_config.json",
41
+ "hooks.json",
42
+ "invitations.json",
43
+ "agent_registry.json",
44
+ "brain_peers.json",
45
+ )
46
+ PORTABLE_EXPORT_SUFFIXES = (".json", ".zip")
47
+
48
+
49
+ def _now() -> str:
50
+ return datetime.now(timezone.utc).isoformat()
51
+
52
+
53
+ def _derive_key(passphrase: str, salt: bytes) -> bytes:
54
+ if not passphrase:
55
+ raise ValueError("A passphrase is required for encrypted .latticebrain archives.")
56
+ kdf = PBKDF2HMAC(
57
+ algorithm=hashes.SHA256(),
58
+ length=32,
59
+ salt=salt,
60
+ iterations=KDF_ITERATIONS,
61
+ )
62
+ return kdf.derive(passphrase.encode("utf-8"))
63
+
64
+
65
+ def _sha256_bytes(data: bytes) -> str:
66
+ return hashlib.sha256(data).hexdigest()
67
+
68
+
69
+ def _sha256_file(path: Path) -> str:
70
+ h = hashlib.sha256()
71
+ with open(path, "rb") as fh:
72
+ for block in iter(lambda: fh.read(65536), b""):
73
+ h.update(block)
74
+ return h.hexdigest()
75
+
76
+
77
+ def _safe_json(value: Any) -> Any:
78
+ try:
79
+ json.dumps(value)
80
+ return value
81
+ except TypeError:
82
+ if isinstance(value, dict):
83
+ return {str(k): _safe_json(v) for k, v in value.items()}
84
+ if isinstance(value, (list, tuple)):
85
+ return [_safe_json(v) for v in value]
86
+ return str(value)
87
+
88
+
89
+ def _assert_safe_member(name: str) -> PurePosixPath:
90
+ path = PurePosixPath(name)
91
+ if path.is_absolute() or ".." in path.parts:
92
+ raise ValueError(f"Archive payload contains unsafe path: {name}")
93
+ return path
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class BrainArchivePaths:
98
+ db_path: Path
99
+ blob_dir: Optional[Path] = None
100
+ data_dir: Optional[Path] = None
101
+ metadata: Optional[Dict[str, Any]] = None
102
+
103
+
104
+ class EncryptedBrainArchive:
105
+ """Create and restore encrypted local Brain Core archives."""
106
+
107
+ def __init__(self, paths: BrainArchivePaths) -> None:
108
+ self.paths = paths
109
+
110
+ def _iter_payload_files(self) -> Iterable[tuple[Path, str]]:
111
+ yield Path(self.paths.db_path), "knowledge_graph.sqlite"
112
+ if self.paths.blob_dir and self.paths.blob_dir.exists():
113
+ blob_root = Path(self.paths.blob_dir)
114
+ for file in sorted(blob_root.rglob("*")):
115
+ if file.is_file():
116
+ yield file, f"blobs/{file.relative_to(blob_root).as_posix()}"
117
+ if self.paths.data_dir and Path(self.paths.data_dir).exists():
118
+ data_root = Path(self.paths.data_dir)
119
+ for name in PORTABLE_DATA_FILES:
120
+ file = data_root / name
121
+ if file.is_file():
122
+ yield file, f"data/{name}"
123
+ exports = data_root / "workspace_exports"
124
+ if exports.exists():
125
+ for file in sorted(exports.rglob("*")):
126
+ if not file.is_file():
127
+ continue
128
+ if file.suffix == ".latticebrain":
129
+ continue
130
+ if file.suffix not in PORTABLE_EXPORT_SUFFIXES:
131
+ continue
132
+ yield file, f"workspace_exports/{file.relative_to(exports).as_posix()}"
133
+
134
+ def _build_manifest(self, entries: List[Dict[str, Any]]) -> Dict[str, Any]:
135
+ metadata = _safe_json(self.paths.metadata or {})
136
+ return {
137
+ "format": "latticebrain.payload",
138
+ "format_version": ARCHIVE_VERSION,
139
+ "created_at": _now(),
140
+ "sections": {
141
+ "graph": any(item["path"] == "knowledge_graph.sqlite" for item in entries),
142
+ "blobs": any(item["path"].startswith("blobs/") for item in entries),
143
+ "workspace_state": any(item["path"].startswith("data/") for item in entries),
144
+ "signed_bundles": any(item["path"].startswith("workspace_exports/") for item in entries),
145
+ },
146
+ "metadata": metadata,
147
+ "storage": metadata.get("storage") or {},
148
+ "device_identity": metadata.get("device_identity") or {},
149
+ "provenance": metadata.get("provenance") or {},
150
+ "entries": entries,
151
+ }
152
+
153
+ def _payload_zip_bytes(self) -> tuple[bytes, Dict[str, Any]]:
154
+ entries: List[Dict[str, Any]] = []
155
+ with tempfile.TemporaryDirectory() as tmp_s:
156
+ payload = Path(tmp_s) / "payload.zip"
157
+ with zipfile.ZipFile(payload, "w", zipfile.ZIP_DEFLATED) as zf:
158
+ for src, arcname in self._iter_payload_files():
159
+ _assert_safe_member(arcname)
160
+ zf.write(src, arcname)
161
+ entries.append({
162
+ "path": arcname,
163
+ "bytes": src.stat().st_size,
164
+ "sha256": _sha256_file(src),
165
+ })
166
+ manifest = self._build_manifest(entries)
167
+ manifest_bytes = json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8")
168
+ zf.writestr("manifest.json", manifest_bytes)
169
+ return payload.read_bytes(), manifest
170
+
171
+ def create(self, destination: Path, *, passphrase: str) -> Dict[str, object]:
172
+ dest = Path(destination)
173
+ if dest.suffix != ".latticebrain":
174
+ dest = dest.with_suffix(".latticebrain")
175
+ if not self.paths.db_path.exists():
176
+ raise FileNotFoundError(f"Brain database not found: {self.paths.db_path}")
177
+ dest.parent.mkdir(parents=True, exist_ok=True)
178
+ payload_bytes, manifest = self._payload_zip_bytes()
179
+ salt = os.urandom(16)
180
+ nonce = os.urandom(12)
181
+ key = _derive_key(passphrase, salt)
182
+ ciphertext = AESGCM(key).encrypt(nonce, payload_bytes, None)
183
+ envelope = {
184
+ "format": ARCHIVE_FORMAT,
185
+ "format_version": ARCHIVE_VERSION,
186
+ "created_at": _now(),
187
+ "kdf": {
188
+ "name": "PBKDF2HMAC-SHA256",
189
+ "iterations": KDF_ITERATIONS,
190
+ "salt": base64.b64encode(salt).decode("ascii"),
191
+ },
192
+ "cipher": {
193
+ "name": "AES-256-GCM",
194
+ "nonce": base64.b64encode(nonce).decode("ascii"),
195
+ },
196
+ "payload_sha256": _sha256_bytes(payload_bytes),
197
+ "manifest_summary": {
198
+ "format_version": manifest["format_version"],
199
+ "created_at": manifest["created_at"],
200
+ "sections": manifest["sections"],
201
+ "storage": manifest.get("storage") or {},
202
+ "device_identity": manifest.get("device_identity") or {},
203
+ },
204
+ "payload": base64.b64encode(ciphertext).decode("ascii"),
205
+ }
206
+ dest.write_text(json.dumps(envelope, indent=2), encoding="utf-8")
207
+ return {
208
+ "path": str(dest),
209
+ "bytes": dest.stat().st_size,
210
+ "encrypted": True,
211
+ "format_version": ARCHIVE_VERSION,
212
+ "manifest": {
213
+ "sections": manifest["sections"],
214
+ "storage": manifest.get("storage") or {},
215
+ "entries": len(manifest["entries"]),
216
+ },
217
+ }
218
+
219
+ def _load_envelope(self, source: Path) -> Dict[str, Any]:
220
+ src = Path(source)
221
+ if not src.exists():
222
+ raise FileNotFoundError(f"Brain archive not found: {src}")
223
+ try:
224
+ envelope = json.loads(src.read_text(encoding="utf-8"))
225
+ except json.JSONDecodeError as exc:
226
+ raise ValueError("Archive envelope is not valid JSON.") from exc
227
+ if envelope.get("format") != ARCHIVE_FORMAT:
228
+ raise ValueError("Not a .latticebrain encrypted archive.")
229
+ version = int(envelope.get("format_version") or 0)
230
+ if version < 1:
231
+ raise ValueError("Archive format version is missing or invalid.")
232
+ if version > ARCHIVE_VERSION:
233
+ raise ValueError(
234
+ f"Archive format version {version} is newer than supported version {ARCHIVE_VERSION}."
235
+ )
236
+ return envelope
237
+
238
+ def inspect(self, source: Path, *, passphrase: Optional[str] = None) -> Dict[str, Any]:
239
+ envelope = self._load_envelope(source)
240
+ summary = {
241
+ "valid_envelope": True,
242
+ "encrypted": True,
243
+ "format": envelope.get("format"),
244
+ "format_version": envelope.get("format_version"),
245
+ "created_at": envelope.get("created_at"),
246
+ "cipher": (envelope.get("cipher") or {}).get("name"),
247
+ "kdf": {
248
+ "name": (envelope.get("kdf") or {}).get("name"),
249
+ "iterations": (envelope.get("kdf") or {}).get("iterations"),
250
+ },
251
+ "manifest_summary": envelope.get("manifest_summary") or {},
252
+ }
253
+ if passphrase:
254
+ verified = self.verify(source, passphrase=passphrase)
255
+ summary["verified"] = verified["ok"]
256
+ summary["manifest"] = verified.get("manifest")
257
+ summary["errors"] = verified.get("errors", [])
258
+ return summary
259
+
260
+ def _decrypt_payload(self, envelope: Dict[str, Any], passphrase: str) -> bytes:
261
+ try:
262
+ salt = base64.b64decode(envelope["kdf"]["salt"])
263
+ nonce = base64.b64decode(envelope["cipher"]["nonce"])
264
+ ciphertext = base64.b64decode(envelope["payload"])
265
+ except Exception as exc:
266
+ raise ValueError("Archive envelope is missing encryption metadata.") from exc
267
+ key = _derive_key(passphrase, salt)
268
+ try:
269
+ payload = AESGCM(key).decrypt(nonce, ciphertext, None)
270
+ except InvalidTag as exc:
271
+ raise ValueError("Archive decryption failed; passphrase or archive data is invalid.") from exc
272
+ expected = envelope.get("payload_sha256")
273
+ if expected and _sha256_bytes(payload) != expected:
274
+ raise ValueError("Archive payload integrity check failed (sha256 mismatch).")
275
+ return payload
276
+
277
+ def _read_payload(self, payload: bytes) -> tuple[Dict[str, Any], Dict[str, bytes]]:
278
+ try:
279
+ with zipfile.ZipFile(io.BytesIO(payload)) as zf:
280
+ bad_member = zf.testzip()
281
+ if bad_member:
282
+ raise ValueError(f"Archive payload member is corrupt: {bad_member}")
283
+ names = zf.namelist()
284
+ for name in names:
285
+ _assert_safe_member(name)
286
+ raw = {name: zf.read(name) for name in names if not name.endswith("/")}
287
+ except zipfile.BadZipFile as exc:
288
+ raise ValueError("Archive payload is not a valid ZIP file.") from exc
289
+ if "manifest.json" in raw:
290
+ manifest = json.loads(raw["manifest.json"].decode("utf-8"))
291
+ else:
292
+ manifest = {
293
+ "format": "latticebrain.payload",
294
+ "format_version": 1,
295
+ "created_at": None,
296
+ "sections": {
297
+ "graph": "knowledge_graph.sqlite" in raw,
298
+ "blobs": any(name.startswith("blobs/") for name in raw),
299
+ "workspace_state": False,
300
+ "signed_bundles": False,
301
+ },
302
+ "metadata": {},
303
+ "storage": {},
304
+ "device_identity": {},
305
+ "provenance": {},
306
+ "entries": [
307
+ {"path": name, "bytes": len(data), "sha256": _sha256_bytes(data)}
308
+ for name, data in raw.items()
309
+ if name != "manifest.json"
310
+ ],
311
+ }
312
+ version = int(manifest.get("format_version") or 0)
313
+ if version < 1:
314
+ raise ValueError("Archive manifest format version is missing or invalid.")
315
+ if version > ARCHIVE_VERSION:
316
+ raise ValueError(
317
+ f"Archive manifest version {version} is newer than supported version {ARCHIVE_VERSION}."
318
+ )
319
+ return manifest, raw
320
+
321
+ def verify(self, source: Path, *, passphrase: str) -> Dict[str, Any]:
322
+ try:
323
+ envelope = self._load_envelope(source)
324
+ payload = self._decrypt_payload(envelope, passphrase)
325
+ manifest, raw = self._read_payload(payload)
326
+ if "knowledge_graph.sqlite" not in raw:
327
+ raise ValueError("Archive payload is missing knowledge_graph.sqlite.")
328
+ missing: List[str] = []
329
+ mismatched: List[str] = []
330
+ for entry in manifest.get("entries") or []:
331
+ name = str(entry.get("path") or "")
332
+ if not name or name == "manifest.json":
333
+ continue
334
+ data = raw.get(name)
335
+ if data is None:
336
+ missing.append(name)
337
+ continue
338
+ if entry.get("sha256") and _sha256_bytes(data) != entry["sha256"]:
339
+ mismatched.append(name)
340
+ if missing or mismatched:
341
+ raise ValueError(
342
+ "Archive manifest integrity check failed "
343
+ f"(missing={missing}, mismatched={mismatched})."
344
+ )
345
+ return {
346
+ "ok": True,
347
+ "encrypted": True,
348
+ "format_version": envelope.get("format_version"),
349
+ "manifest": manifest,
350
+ "entries": len([name for name in raw if name != "manifest.json"]),
351
+ "errors": [],
352
+ }
353
+ except (ValueError, FileNotFoundError) as exc:
354
+ return {"ok": False, "encrypted": True, "errors": [str(exc)]}
355
+
356
+ def restore(
357
+ self,
358
+ source: Path,
359
+ *,
360
+ passphrase: str,
361
+ target: BrainArchivePaths,
362
+ dry_run: bool = False,
363
+ confirm: bool = False,
364
+ ) -> Dict[str, object]:
365
+ if not dry_run and not confirm:
366
+ raise ValueError("Explicit confirmation is required before restoring a .latticebrain archive.")
367
+ envelope = self._load_envelope(source)
368
+ payload = self._decrypt_payload(envelope, passphrase)
369
+ manifest, raw = self._read_payload(payload)
370
+ verified = self.verify(source, passphrase=passphrase)
371
+ if not verified["ok"]:
372
+ raise ValueError("; ".join(verified.get("errors") or ["Archive verification failed."]))
373
+ planned = {
374
+ "database": str(target.db_path),
375
+ "blobs": str(target.blob_dir) if target.blob_dir else None,
376
+ "data_dir": str(target.data_dir) if target.data_dir else None,
377
+ "entries": len([name for name in raw if name != "manifest.json"]),
378
+ "sections": manifest.get("sections") or {},
379
+ }
380
+ if dry_run:
381
+ return {
382
+ "restored": False,
383
+ "dry_run": True,
384
+ "verified": True,
385
+ "planned": planned,
386
+ "manifest": manifest,
387
+ }
388
+ with tempfile.TemporaryDirectory() as tmp_s:
389
+ tmp = Path(tmp_s)
390
+ out = tmp / "out"
391
+ out.mkdir()
392
+ for name, data in raw.items():
393
+ _assert_safe_member(name)
394
+ dest = out / name
395
+ dest.parent.mkdir(parents=True, exist_ok=True)
396
+ dest.write_bytes(data)
397
+ db_src = out / "knowledge_graph.sqlite"
398
+ if not db_src.exists():
399
+ raise ValueError("Archive payload is missing knowledge_graph.sqlite.")
400
+ target.db_path.parent.mkdir(parents=True, exist_ok=True)
401
+ for sibling in (target.db_path, Path(str(target.db_path) + "-wal"), Path(str(target.db_path) + "-shm")):
402
+ if sibling.exists():
403
+ sibling.unlink()
404
+ shutil.copyfile(db_src, target.db_path)
405
+ blobs_src = out / "blobs"
406
+ if target.blob_dir:
407
+ if target.blob_dir.exists():
408
+ shutil.rmtree(target.blob_dir)
409
+ if blobs_src.exists():
410
+ shutil.copytree(blobs_src, target.blob_dir)
411
+ else:
412
+ target.blob_dir.mkdir(parents=True, exist_ok=True)
413
+ if target.data_dir:
414
+ data_src = out / "data"
415
+ exports_src = out / "workspace_exports"
416
+ target_data = Path(target.data_dir)
417
+ target_data.mkdir(parents=True, exist_ok=True)
418
+ if data_src.exists():
419
+ for file in sorted(data_src.rglob("*")):
420
+ if file.is_file():
421
+ rel = file.relative_to(data_src)
422
+ if rel.as_posix() not in PORTABLE_DATA_FILES:
423
+ continue
424
+ dest = target_data / rel
425
+ dest.parent.mkdir(parents=True, exist_ok=True)
426
+ shutil.copyfile(file, dest)
427
+ if exports_src.exists():
428
+ exports_dest = target_data / "workspace_exports"
429
+ exports_dest.mkdir(parents=True, exist_ok=True)
430
+ for file in sorted(exports_src.rglob("*")):
431
+ if file.is_file():
432
+ rel = file.relative_to(exports_src)
433
+ dest = exports_dest / rel
434
+ dest.parent.mkdir(parents=True, exist_ok=True)
435
+ shutil.copyfile(file, dest)
436
+ return {
437
+ "restored": True,
438
+ "dry_run": False,
439
+ "path": str(target.db_path),
440
+ "encrypted": True,
441
+ "verified": True,
442
+ "manifest": manifest,
443
+ }
444
+
445
+
446
+ __all__ = ["BrainArchivePaths", "EncryptedBrainArchive"]
@@ -0,0 +1,3 @@
1
+ from latticeai.brain.context import AssembledContext, ContextAssembler, ContextSection, approx_tokens
2
+
3
+ __all__ = ["AssembledContext", "ContextAssembler", "ContextSection", "approx_tokens"]