albert-memory 0.4.0__py3-none-any.whl

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 (74) hide show
  1. albert/__init__.py +3 -0
  2. albert/admin.py +532 -0
  3. albert/api.py +698 -0
  4. albert/automatic_memory_release.json +14 -0
  5. albert/benchmark.py +272 -0
  6. albert/benchmark_cli.py +37 -0
  7. albert/capture.py +1051 -0
  8. albert/capture_installer.py +1000 -0
  9. albert/capture_promotion.py +298 -0
  10. albert/capture_retention.py +72 -0
  11. albert/capture_sdk.py +558 -0
  12. albert/capture_security.py +79 -0
  13. albert/chunking.py +79 -0
  14. albert/classifier.py +263 -0
  15. albert/claude_adapter.py +357 -0
  16. albert/claude_lifecycle.py +418 -0
  17. albert/claude_package.json +19 -0
  18. albert/claude_plugin/.claude-plugin/plugin.json +11 -0
  19. albert/claude_plugin/.mcp.json +13 -0
  20. albert/claude_plugin/hooks/hooks.json +70 -0
  21. albert/clusters.py +140 -0
  22. albert/codex_adapter.py +308 -0
  23. albert/codex_hooks/hooks.json +49 -0
  24. albert/codex_lifecycle.py +485 -0
  25. albert/codex_package.json +19 -0
  26. albert/compatibility.py +121 -0
  27. albert/config.py +144 -0
  28. albert/console.py +508 -0
  29. albert/console_backends.py +236 -0
  30. albert/console_map.py +518 -0
  31. albert/console_ops.py +919 -0
  32. albert/credentials.py +616 -0
  33. albert/cursor_adapter.py +320 -0
  34. albert/cursor_hooks/hooks.json +11 -0
  35. albert/cursor_rules/albert-memory.mdc +11 -0
  36. albert/db.py +68 -0
  37. albert/embeddings.py +91 -0
  38. albert/graph.py +623 -0
  39. albert/graph_builders.py +879 -0
  40. albert/graph_cypher.py +669 -0
  41. albert/graph_store.py +376 -0
  42. albert/host_targets/claude-code.json +45 -0
  43. albert/host_targets/codex.json +45 -0
  44. albert/host_targets/openclaw.json +45 -0
  45. albert/host_targets.py +271 -0
  46. albert/links.py +428 -0
  47. albert/mcp_server.py +457 -0
  48. albert/memory_safety.py +111 -0
  49. albert/models.py +611 -0
  50. albert/openclaw_adapter.py +326 -0
  51. albert/openclaw_adapter_trust.json +5 -0
  52. albert/openclaw_package.json +22 -0
  53. albert/openclaw_plugin/index.js +135 -0
  54. albert/openclaw_plugin/openclaw.plugin.json +25 -0
  55. albert/openclaw_plugin/package.json +12 -0
  56. albert/portability.py +291 -0
  57. albert/provenance.py +105 -0
  58. albert/recorder.py +166 -0
  59. albert/release_gate.py +83 -0
  60. albert/schemas.py +617 -0
  61. albert/scope.py +26 -0
  62. albert/search.py +447 -0
  63. albert/security.py +150 -0
  64. albert/services.py +1316 -0
  65. albert/session_context.py +162 -0
  66. albert/trace_feed.py +165 -0
  67. albert/trace_writer.py +131 -0
  68. albert/tracing.py +75 -0
  69. albert/worker.py +202 -0
  70. albert/workspaces.py +256 -0
  71. albert_memory-0.4.0.dist-info/METADATA +554 -0
  72. albert_memory-0.4.0.dist-info/RECORD +74 -0
  73. albert_memory-0.4.0.dist-info/WHEEL +4 -0
  74. albert_memory-0.4.0.dist-info/entry_points.txt +14 -0
@@ -0,0 +1,1000 @@
1
+ """Safe installer, doctor, and uninstaller for Albert capture adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import importlib.metadata
7
+ import json
8
+ import os
9
+ import re
10
+ import shlex
11
+ import shutil
12
+ import stat
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ import uuid
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ import httpx
22
+ import typer
23
+ from cryptography.exceptions import InvalidSignature
24
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
25
+
26
+ from albert.capture_sdk import CaptureClient, EncryptedQueue, generate_queue_key
27
+ from albert.host_targets import (
28
+ HostTargetError,
29
+ assert_owned_install_root,
30
+ negotiate_host_target,
31
+ )
32
+ from albert.openclaw_adapter import OPENCLAW_EVENTS
33
+
34
+ app = typer.Typer(help="Connect supported agent hosts directly to an Albert Brain.")
35
+ PLUGIN_ID = "albert-memory"
36
+ MIN_OPENCLAW = (2026, 9, 6)
37
+ MAX_OPENCLAW = (2027, 0, 0)
38
+ ID_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{1,200}$")
39
+ SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
40
+ CAPABILITY_PATTERN = re.compile(r"^[a-z][a-z0-9._-]{0,99}$")
41
+ TRUST_PATH = Path(__file__).with_name("openclaw_adapter_trust.json")
42
+ VERSION_PATTERN = re.compile(r"OpenClaw\s+(\d+)\.(\d+)\.(\d+)")
43
+
44
+
45
+ class InstallError(RuntimeError):
46
+ """An expected, safely reportable installer failure."""
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class InstallPaths:
51
+ root: Path
52
+
53
+ @property
54
+ def credentials(self) -> Path:
55
+ return self.root / "credentials.env"
56
+
57
+ @property
58
+ def wrapper(self) -> Path:
59
+ return self.root / "bin" / "albert-openclaw-adapter"
60
+
61
+ @property
62
+ def marker(self) -> Path:
63
+ return self.root / "install.json"
64
+
65
+ @property
66
+ def queue(self) -> Path:
67
+ return self.root / "capture.db"
68
+
69
+
70
+ Runner = Callable[[list[str]], subprocess.CompletedProcess[str]]
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class CloudManifest:
75
+ capabilities: list[str]
76
+ compensation_url: str
77
+ identity: dict[str, str]
78
+ manifest_id: str
79
+ manifest_version: str
80
+ redemption_url: str
81
+ rest_url: str
82
+
83
+
84
+ def _canonical_json(value: object) -> str:
85
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
86
+
87
+
88
+ def _urlsafe_decode(value: str) -> bytes:
89
+ import base64
90
+ import binascii
91
+
92
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", value) or len(value) > 8192:
93
+ raise InstallError("signed adapter metadata is malformed")
94
+ try:
95
+ return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
96
+ except (binascii.Error, ValueError):
97
+ raise InstallError("signed adapter metadata is malformed") from None
98
+
99
+
100
+ def _https_url(value: object, label: str) -> str:
101
+ from ipaddress import ip_address
102
+ from urllib.parse import urlsplit, urlunsplit
103
+
104
+ if not isinstance(value, str) or len(value) > 4096:
105
+ raise InstallError(f"{label} must be a canonical public HTTPS URL")
106
+ try:
107
+ parsed = urlsplit(value)
108
+ port = parsed.port
109
+ except ValueError:
110
+ raise InstallError(f"{label} must be a canonical public HTTPS URL") from None
111
+ hostname = (parsed.hostname or "").rstrip(".").lower()
112
+ try:
113
+ address = ip_address(hostname)
114
+ except ValueError:
115
+ address = None
116
+ private_name = address is None and (
117
+ "." not in hostname or hostname.endswith((".local", ".localhost", ".internal", ".lan"))
118
+ )
119
+ if (
120
+ parsed.scheme != "https"
121
+ or not hostname
122
+ or parsed.username is not None
123
+ or parsed.password is not None
124
+ or port is not None
125
+ or parsed.fragment
126
+ or (address is not None and not address.is_global)
127
+ or private_name
128
+ ):
129
+ raise InstallError(f"{label} must be a canonical public HTTPS URL")
130
+ canonical_host = f"[{hostname}]" if address is not None and address.version == 6 else hostname
131
+ canonical = urlunsplit(
132
+ ("https", canonical_host, parsed.path.rstrip("/") or "/", parsed.query, "")
133
+ )
134
+ if canonical.rstrip("/") != value.rstrip("/"):
135
+ raise InstallError(f"{label} must be a canonical public HTTPS URL")
136
+ return value.rstrip("/")
137
+
138
+
139
+ def _load_trust() -> dict[str, object]:
140
+ try:
141
+ trust = json.loads(TRUST_PATH.read_text(encoding="utf-8"))
142
+ except (OSError, json.JSONDecodeError) as exc:
143
+ raise InstallError("OpenClaw adapter trust metadata is unavailable") from exc
144
+ if not isinstance(trust, dict):
145
+ raise InstallError("OpenClaw adapter trust metadata is invalid")
146
+ return trust
147
+
148
+
149
+ def _package_version() -> str:
150
+ try:
151
+ return importlib.metadata.version("albert-memory")
152
+ except importlib.metadata.PackageNotFoundError:
153
+ return "0.4.0"
154
+
155
+
156
+ def _version_in_range(version: tuple[int, int, int], declared: str) -> bool:
157
+ clauses = [part.strip() for part in declared.split(",") if part.strip()]
158
+ if not clauses:
159
+ return False
160
+ for clause in clauses:
161
+ match = re.fullmatch(r"(>=|>|<=|<|==)(\d+)\.(\d+)\.(\d+)", clause)
162
+ if not match:
163
+ return False
164
+ target = tuple(int(value) for value in match.groups()[1:])
165
+ operator = match.group(1)
166
+ if not {
167
+ ">=": version >= target,
168
+ ">": version > target,
169
+ "<=": version <= target,
170
+ "<": version < target,
171
+ "==": version == target,
172
+ }[operator]:
173
+ return False
174
+ return True
175
+
176
+
177
+ def _verify_manifest(
178
+ payload: object,
179
+ *,
180
+ link_url: str,
181
+ openclaw_version: tuple[int, int, int],
182
+ trust: dict[str, object] | None = None,
183
+ package_version: str | None = None,
184
+ ) -> CloudManifest:
185
+ from datetime import UTC, datetime, timedelta
186
+ from urllib.parse import urlsplit, urlunsplit
187
+
188
+ if not isinstance(payload, dict):
189
+ raise InstallError("Cloud adapter manifest is malformed")
190
+ signature = payload.get("signature")
191
+ unsigned = {key: value for key, value in payload.items() if key != "signature"}
192
+ if not isinstance(signature, dict) or set(signature) != {"algorithm", "key_id", "value"}:
193
+ raise InstallError("Cloud adapter manifest signature is malformed")
194
+ key_id = signature.get("key_id")
195
+ loaded_trust = trust if trust is not None else _load_trust()
196
+ keys = loaded_trust.get("manifest_keys")
197
+ if (
198
+ signature.get("algorithm") != "Ed25519"
199
+ or not isinstance(key_id, str)
200
+ or not isinstance(keys, dict)
201
+ or not isinstance(keys.get(key_id), str)
202
+ or not isinstance(signature.get("value"), str)
203
+ ):
204
+ raise InstallError("Cloud adapter manifest is not signed by a trusted key")
205
+ try:
206
+ public_key = Ed25519PublicKey.from_public_bytes(_urlsafe_decode(keys[key_id]))
207
+ public_key.verify(
208
+ _urlsafe_decode(signature["value"]), _canonical_json(unsigned).encode("utf-8")
209
+ )
210
+ except (ValueError, InvalidSignature):
211
+ raise InstallError("Cloud adapter manifest signature is invalid") from None
212
+
213
+ if set(unsigned) != {
214
+ "adapter",
215
+ "capabilities",
216
+ "compensation",
217
+ "connection",
218
+ "expires_at",
219
+ "identity",
220
+ "issued_at",
221
+ "manifest_id",
222
+ "manifest_version",
223
+ "redemption",
224
+ "reload",
225
+ "revocation",
226
+ "rollback",
227
+ "schema",
228
+ "verification",
229
+ }:
230
+ raise InstallError("Cloud adapter manifest contains unknown or missing fields")
231
+ version = unsigned.get("manifest_version")
232
+ if not isinstance(version, str) or version.split(".")[0] != "2":
233
+ raise InstallError("unsupported Cloud adapter manifest major version")
234
+ if unsigned.get("schema") != "com.jiggai.albert.adapter-manifest":
235
+ raise InstallError("Cloud adapter manifest schema is unsupported")
236
+ issued_value = unsigned.get("issued_at")
237
+ expires_value = unsigned.get("expires_at")
238
+ if not isinstance(issued_value, str) or not isinstance(expires_value, str):
239
+ raise InstallError("Cloud adapter manifest lifetime is malformed")
240
+ try:
241
+ issued = datetime.fromisoformat(issued_value.replace("Z", "+00:00"))
242
+ expires = datetime.fromisoformat(expires_value.replace("Z", "+00:00"))
243
+ except ValueError:
244
+ raise InstallError("Cloud adapter manifest lifetime is malformed") from None
245
+ now = datetime.now(UTC)
246
+ if (
247
+ issued.utcoffset() != timedelta(0)
248
+ or expires.utcoffset() != timedelta(0)
249
+ or issued > now
250
+ or expires <= now
251
+ or expires - issued > timedelta(hours=1)
252
+ ):
253
+ raise InstallError("Cloud adapter manifest is expired or not yet valid")
254
+
255
+ identity = unsigned.get("identity")
256
+ capabilities = unsigned.get("capabilities")
257
+ manifest_id = unsigned.get("manifest_id")
258
+ identity_keys = ("brain_id", "request_id", "workspace_id")
259
+ if (
260
+ not isinstance(identity, dict)
261
+ or set(identity) != {"brain_id", "cloud", "request_id", "workspace_id"}
262
+ or identity.get("cloud") != "albert-cloud"
263
+ or not all(
264
+ isinstance(identity.get(key), str) and ID_PATTERN.fullmatch(identity[key])
265
+ for key in identity_keys
266
+ )
267
+ or identity.get("request_id") != manifest_id
268
+ or not isinstance(capabilities, list)
269
+ or not capabilities
270
+ or capabilities != sorted(set(capabilities))
271
+ or not all(
272
+ isinstance(item, str) and CAPABILITY_PATTERN.fullmatch(item) for item in capabilities
273
+ )
274
+ ):
275
+ raise InstallError("Cloud adapter manifest identity or capabilities are invalid")
276
+
277
+ adapter = unsigned.get("adapter")
278
+ if (
279
+ not isinstance(adapter, dict)
280
+ or set(adapter)
281
+ != {
282
+ "artifact_sha256",
283
+ "artifact_signature",
284
+ "artifact_url",
285
+ "family",
286
+ "package",
287
+ "package_version",
288
+ "supported_openclaw",
289
+ }
290
+ ):
291
+ raise InstallError("Cloud adapter package provenance is malformed")
292
+ local_version = package_version or _package_version()
293
+ artifact_sha256 = adapter.get("artifact_sha256")
294
+ if (
295
+ adapter.get("family") != "openclaw"
296
+ or adapter.get("package") != "albert-memory"
297
+ or adapter.get("package_version") != local_version
298
+ or not isinstance(artifact_sha256, str)
299
+ or not SHA256_PATTERN.fullmatch(artifact_sha256)
300
+ or adapter.get("artifact_signature")
301
+ != f"github-oidc-dsse:sha256:{artifact_sha256}"
302
+ or not isinstance(adapter.get("supported_openclaw"), str)
303
+ or not _version_in_range(openclaw_version, adapter["supported_openclaw"])
304
+ ):
305
+ raise InstallError("Cloud adapter package provenance or host compatibility is untrusted")
306
+ _https_url(adapter.get("artifact_url"), "Adapter artifact")
307
+
308
+ parsed_link = urlsplit(link_url)
309
+ base_path = parsed_link.path.rstrip("/")
310
+ if not re.fullmatch(r".*/onboard/[A-Za-z0-9_-]{20,128}", base_path):
311
+ raise InstallError("Cloud one-time link path is invalid")
312
+ rest_path = base_path.rsplit("/onboard/", 1)[0]
313
+ expected_rest = urlunsplit((parsed_link.scheme, parsed_link.netloc, rest_path, "", "")).rstrip(
314
+ "/"
315
+ )
316
+ connection = unsigned.get("connection")
317
+ redemption = unsigned.get("redemption")
318
+ compensation = unsigned.get("compensation")
319
+ reload_contract = unsigned.get("reload")
320
+ revocation = unsigned.get("revocation")
321
+ rollback = unsigned.get("rollback")
322
+ verification = unsigned.get("verification")
323
+ if (
324
+ not all(isinstance(value, dict) for value in (connection, redemption, compensation))
325
+ or set(connection) != {"authorization", "rest_url"}
326
+ or set(redemption) != {"identity_bound", "method", "single_use", "url"}
327
+ or set(compensation) != {"credential_specific", "method", "receipt_ttl_seconds", "url"}
328
+ ):
329
+ raise InstallError("Cloud adapter endpoints are malformed")
330
+ rest_url = _https_url(connection.get("rest_url"), "Brain endpoint")
331
+ redemption_url = _https_url(redemption.get("url"), "Redemption endpoint")
332
+ compensation_url = _https_url(compensation.get("url"), "Compensation endpoint")
333
+ expected_redeem = urlunsplit(
334
+ (parsed_link.scheme, parsed_link.netloc, base_path + "/redeem/v2", parsed_link.query, "")
335
+ )
336
+ if (
337
+ rest_url != expected_rest
338
+ or connection.get("authorization") != "***"
339
+ or redemption.get("method") != "POST"
340
+ or redemption.get("single_use") is not True
341
+ or redemption.get("identity_bound") is not True
342
+ or redemption_url != expected_redeem
343
+ or compensation.get("method") != "POST"
344
+ or compensation.get("credential_specific") is not True
345
+ or compensation.get("receipt_ttl_seconds") != 600
346
+ or compensation_url != f"{expected_rest}/onboard/compensate"
347
+ or reload_contract != {"command": ["openclaw", "plugins", "reload", PLUGIN_ID]}
348
+ or revocation
349
+ != {
350
+ "credential_id_issued_on_redemption": True,
351
+ "independent_agent": True,
352
+ "scope": "single-credential",
353
+ }
354
+ or rollback
355
+ != {
356
+ "compensate_before_commit": True,
357
+ "ownership_marker": PLUGIN_ID,
358
+ "restore_previous_state": True,
359
+ }
360
+ or verification
361
+ != {
362
+ "capture_recall_smoke": True,
363
+ "health": "/v1/health/ready",
364
+ "runtime_inspection": True,
365
+ }
366
+ ):
367
+ raise InstallError("Cloud adapter endpoint binding is invalid")
368
+ return CloudManifest(
369
+ capabilities=capabilities,
370
+ compensation_url=compensation_url,
371
+ identity={key: str(identity[key]) for key in identity_keys},
372
+ manifest_id=str(manifest_id),
373
+ manifest_version=version,
374
+ redemption_url=redemption_url,
375
+ rest_url=rest_url,
376
+ )
377
+
378
+
379
+ def _run(command: list[str]) -> subprocess.CompletedProcess[str]:
380
+ return subprocess.run(command, text=True, capture_output=True, check=False)
381
+
382
+
383
+ def _checked(runner: Runner, command: list[str], label: str) -> subprocess.CompletedProcess[str]:
384
+ result = runner(command)
385
+ if result.returncode:
386
+ detail = (result.stderr or result.stdout).strip().splitlines()
387
+ suffix = f": {detail[-1]}" if detail else ""
388
+ raise InstallError(f"{label} failed{suffix}")
389
+ return result
390
+
391
+
392
+ def _detect_openclaw(runner: Runner) -> tuple[int, int, int]:
393
+ result = _checked(runner, ["openclaw", "--version"], "OpenClaw detection")
394
+ match = VERSION_PATTERN.search(result.stdout)
395
+ if not match:
396
+ raise InstallError("unsupported OpenClaw version output")
397
+ version = tuple(int(value) for value in match.groups())
398
+ if not MIN_OPENCLAW <= version < MAX_OPENCLAW:
399
+ rendered = ".".join(str(value) for value in version)
400
+ raise InstallError(f"OpenClaw {rendered} is unsupported; require >=2026.9.6 and <2027")
401
+ return version
402
+
403
+
404
+ def _validate_secret(value: str) -> str:
405
+ if not value or any(character in value for character in "\r\n\0"):
406
+ raise InstallError("credential must be non-empty and contain no control characters")
407
+ return value
408
+
409
+
410
+ def _write_private(path: Path, content: str, mode: int) -> None:
411
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
412
+ path.write_text(content, encoding="utf-8")
413
+ os.chmod(path, mode)
414
+
415
+
416
+ def _credentials_text(
417
+ api_url: str, token: str, queue_key: str, queue_path: Path, workspace_id: str | None
418
+ ) -> str:
419
+ values = {
420
+ "ALBERT_API_URL": api_url.rstrip("/"),
421
+ "ALBERT_CAPTURE_TOKEN": token,
422
+ "ALBERT_CAPTURE_QUEUE_KEY": queue_key,
423
+ "ALBERT_CAPTURE_QUEUE_PATH": str(queue_path),
424
+ "ALBERT_CAPTURE_SCOPE": "openclaw:default",
425
+ }
426
+ if workspace_id:
427
+ values["ALBERT_WORKSPACE_ID"] = workspace_id
428
+ return json.dumps(values, sort_keys=True) + "\n"
429
+
430
+
431
+ def _wrapper_text(credentials: Path) -> str:
432
+ python = shlex.quote(sys.executable)
433
+ secret_file = shlex.quote(str(credentials))
434
+ return (
435
+ "#!/bin/sh\n"
436
+ f"export ALBERT_CAPTURE_CREDENTIAL_FILE={secret_file}\n"
437
+ f"exec {python} -m albert.openclaw_adapter\n"
438
+ )
439
+
440
+
441
+ def _brain_check(
442
+ api_url: str,
443
+ token: str,
444
+ queue_key: str,
445
+ workspace_id: str | None,
446
+ *,
447
+ smoke: bool,
448
+ event_classes: frozenset[str] = OPENCLAW_EVENTS,
449
+ ) -> None:
450
+ base_url = api_url.rstrip("/")
451
+ try:
452
+ with httpx.Client(
453
+ base_url=base_url,
454
+ headers={"Authorization": f"Bearer {token}"},
455
+ timeout=10,
456
+ ) as http:
457
+ ready = http.get("/v1/health/ready")
458
+ if not ready.is_success:
459
+ raise InstallError(f"Brain readiness check failed ({ready.status_code})")
460
+ with tempfile.TemporaryDirectory(prefix="albert-install-check-") as temporary:
461
+ queue = EncryptedQueue(Path(temporary) / "capture.db", queue_key, "installer:smoke")
462
+ client = CaptureClient(
463
+ base_url,
464
+ token,
465
+ queue,
466
+ local_event_classes=event_classes,
467
+ client=http,
468
+ )
469
+ if not client.negotiate():
470
+ raise InstallError("Brain lacks the required capture/recall capabilities")
471
+ if not smoke:
472
+ return
473
+ nonce = uuid.uuid4().hex
474
+ session_key = f"installer:start:{nonce}"
475
+ start = client.start_session(
476
+ session_key,
477
+ session_key,
478
+ workspace_id=workspace_id,
479
+ provenance={"client": "installer", "event": "live_smoke"},
480
+ )
481
+ event = client.record_event(
482
+ session_key,
483
+ 1,
484
+ "user_goal",
485
+ f"Albert installer smoke {nonce}",
486
+ f"installer:event:{nonce}",
487
+ provenance={"client": "installer", "event": "live_smoke"},
488
+ )
489
+ recalled = client.recall(f"Albert installer smoke {nonce}", max_items=1)
490
+ end = client.end_session(session_key, 1, f"installer:end:{nonce}")
491
+ if any(item.status != "delivered" for item in (start, event, end)):
492
+ raise InstallError("live capture smoke test was rejected")
493
+ if "ALBERT REFERENCE CONTEXT" not in recalled:
494
+ raise InstallError("live recall smoke test returned an invalid response")
495
+ except httpx.HTTPError as exc:
496
+ raise InstallError(f"direct Brain connection failed ({type(exc).__name__})") from None
497
+
498
+
499
+ def install_openclaw(
500
+ *,
501
+ api_url: str,
502
+ token: str,
503
+ workspace_id: str | None,
504
+ paths: InstallPaths,
505
+ runner: Runner = _run,
506
+ brain_check: Callable[[str, str, str, str | None], None] | None = None,
507
+ cloud_metadata: dict[str, object] | None = None,
508
+ package_version: str | None = None,
509
+ ) -> None:
510
+ version = _detect_openclaw(runner)
511
+ installed_package_version = package_version or _package_version()
512
+ try:
513
+ target = negotiate_host_target(
514
+ "openclaw", version, {"capture", "recall", "reload", "rollback", "uninstall"}
515
+ )
516
+ assert_owned_install_root(paths.root, expected_marker=target.ownership["marker"])
517
+ except HostTargetError as exc:
518
+ raise InstallError(str(exc)) from None
519
+ token = _validate_secret(token)
520
+ api_url = api_url.rstrip("/")
521
+ if not api_url.startswith(("http://", "https://")):
522
+ raise InstallError("Brain URL must use http:// or https://")
523
+
524
+ previous_values: dict[str, str] | None = None
525
+ if paths.root.exists():
526
+ previous_values = _load_credentials(paths)
527
+ queue_key = (
528
+ previous_values.get("ALBERT_CAPTURE_QUEUE_KEY")
529
+ if previous_values is not None
530
+ else generate_queue_key()
531
+ )
532
+ if not queue_key:
533
+ raise InstallError("existing capture queue key is invalid")
534
+ check = brain_check or (
535
+ lambda url, secret, key, workspace: _brain_check(url, secret, key, workspace, smoke=True)
536
+ )
537
+ check(api_url, token, queue_key, workspace_id)
538
+
539
+ config_path = Path(
540
+ _checked(runner, ["openclaw", "config", "file"], "config discovery").stdout.strip()
541
+ ).expanduser()
542
+ config_existed = config_path.exists()
543
+ config_before = config_path.read_bytes() if config_existed else None
544
+ config_mode = stat.S_IMODE(config_path.stat().st_mode) if config_existed else 0o600
545
+ backup = paths.root.with_name(f".{paths.root.name}.rollback-{uuid.uuid4().hex}")
546
+ staging = paths.root.with_name(f".{paths.root.name}.staging-{uuid.uuid4().hex}")
547
+ plugin_path = Path(__file__).with_name("openclaw_plugin")
548
+ previous_install = paths.root.exists()
549
+ plugin_installed = False
550
+ try:
551
+ staged = InstallPaths(staging)
552
+ _write_private(
553
+ staged.credentials,
554
+ _credentials_text(api_url, token, queue_key, paths.queue, workspace_id),
555
+ 0o600,
556
+ )
557
+ _write_private(staged.wrapper, _wrapper_text(paths.credentials), 0o700)
558
+ if paths.queue.exists():
559
+ shutil.copy2(paths.queue, staged.queue)
560
+ os.chmod(staged.queue, 0o600)
561
+ marker = {
562
+ "owner": "albert-memory",
563
+ "host": "openclaw",
564
+ "host_target_schema": target.schema_version,
565
+ "host_target_version": target.descriptor_version,
566
+ "package_version": installed_package_version,
567
+ "api_url": api_url,
568
+ "openclaw_version": ".".join(str(value) for value in version),
569
+ }
570
+ if cloud_metadata is not None:
571
+ marker["cloud"] = cloud_metadata
572
+ _write_private(staged.marker, json.dumps(marker, sort_keys=True) + "\n", 0o600)
573
+
574
+ if paths.root.exists():
575
+ paths.root.rename(backup)
576
+ staging.rename(paths.root)
577
+ _checked(
578
+ runner,
579
+ [
580
+ "openclaw",
581
+ "plugins",
582
+ "install",
583
+ "--link",
584
+ "--force",
585
+ "--accept-capabilities",
586
+ str(plugin_path),
587
+ ],
588
+ "OpenClaw plugin install",
589
+ )
590
+ plugin_installed = True
591
+ entry = {
592
+ "enabled": True,
593
+ "hooks": {"allowPromptInjection": True, "allowConversationAccess": True},
594
+ "config": {"command": str(paths.wrapper), "timeoutMs": 1800},
595
+ }
596
+ _checked(
597
+ runner,
598
+ [
599
+ "openclaw",
600
+ "config",
601
+ "set",
602
+ f"plugins.entries.{PLUGIN_ID}",
603
+ json.dumps(entry, separators=(",", ":")),
604
+ "--strict-json",
605
+ ],
606
+ "OpenClaw plugin configuration",
607
+ )
608
+ _checked(runner, ["openclaw", "config", "validate"], "OpenClaw config validation")
609
+ _checked(
610
+ runner,
611
+ ["openclaw", "plugins", "reload", PLUGIN_ID],
612
+ "OpenClaw plugin reload",
613
+ )
614
+ _checked(
615
+ runner,
616
+ ["openclaw", "plugins", "inspect", PLUGIN_ID, "--runtime", "--json"],
617
+ "OpenClaw runtime inspection",
618
+ )
619
+ check(api_url, token, queue_key, workspace_id)
620
+ except Exception as exc:
621
+ cleanup_error: Exception | None = None
622
+ if plugin_installed and not previous_install:
623
+ try:
624
+ _checked(
625
+ runner,
626
+ ["openclaw", "plugins", "uninstall", PLUGIN_ID, "--force"],
627
+ "OpenClaw plugin rollback",
628
+ )
629
+ except Exception as rollback_exc:
630
+ cleanup_error = rollback_exc
631
+ if paths.root.exists():
632
+ shutil.rmtree(paths.root)
633
+ if backup.exists():
634
+ backup.rename(paths.root)
635
+ if config_before is not None:
636
+ config_path.parent.mkdir(parents=True, exist_ok=True)
637
+ temporary = config_path.with_name(f".{config_path.name}.albert-rollback")
638
+ temporary.write_bytes(config_before)
639
+ os.chmod(temporary, config_mode)
640
+ os.replace(temporary, config_path)
641
+ elif not config_existed and config_path.exists():
642
+ config_path.unlink()
643
+ if staging.exists():
644
+ shutil.rmtree(staging)
645
+ if cleanup_error is not None:
646
+ raise InstallError(
647
+ f"{exc}; automatic plugin rollback failed ({cleanup_error}). "
648
+ f"Run `openclaw plugins uninstall {PLUGIN_ID} --force`, then retry."
649
+ ) from exc
650
+ raise
651
+ else:
652
+ if backup.exists():
653
+ shutil.rmtree(backup)
654
+
655
+
656
+ def _load_credentials(paths: InstallPaths) -> dict[str, str]:
657
+ if not paths.marker.exists() or not paths.credentials.exists():
658
+ raise InstallError("no Albert-owned OpenClaw installation found")
659
+ marker = json.loads(paths.marker.read_text(encoding="utf-8"))
660
+ if marker.get("owner") != "albert-memory":
661
+ raise InstallError("installation ownership marker is invalid")
662
+ values = json.loads(paths.credentials.read_text(encoding="utf-8"))
663
+ if not isinstance(values, dict) or not all(
664
+ isinstance(key, str) and isinstance(value, str) for key, value in values.items()
665
+ ):
666
+ raise InstallError("credential file is invalid")
667
+ return values
668
+
669
+
670
+ def doctor_openclaw(paths: InstallPaths, *, smoke: bool = False, runner: Runner = _run) -> None:
671
+ version = _detect_openclaw(runner)
672
+ try:
673
+ target = negotiate_host_target("openclaw", version, {"capture", "recall"})
674
+ assert_owned_install_root(paths.root, expected_marker=target.ownership["marker"])
675
+ except HostTargetError as exc:
676
+ raise InstallError(str(exc)) from None
677
+ values = _load_credentials(paths)
678
+ _checked(runner, ["openclaw", "config", "validate"], "OpenClaw config validation")
679
+ _checked(
680
+ runner,
681
+ ["openclaw", "plugins", "inspect", PLUGIN_ID, "--runtime", "--json"],
682
+ "OpenClaw runtime inspection",
683
+ )
684
+ _brain_check(
685
+ values["ALBERT_API_URL"],
686
+ values["ALBERT_CAPTURE_TOKEN"],
687
+ values["ALBERT_CAPTURE_QUEUE_KEY"],
688
+ values.get("ALBERT_WORKSPACE_ID"),
689
+ smoke=smoke,
690
+ )
691
+
692
+
693
+ def uninstall_openclaw(paths: InstallPaths, *, runner: Runner = _run) -> None:
694
+ version = _detect_openclaw(runner)
695
+ try:
696
+ target = negotiate_host_target("openclaw", version, {"uninstall"})
697
+ assert_owned_install_root(paths.root, expected_marker=target.ownership["marker"])
698
+ except HostTargetError as exc:
699
+ raise InstallError(str(exc)) from None
700
+ _load_credentials(paths)
701
+ result = runner(["openclaw", "plugins", "uninstall", PLUGIN_ID, "--force"])
702
+ if result.returncode:
703
+ _checked(
704
+ runner,
705
+ ["openclaw", "config", "unset", f"plugins.entries.{PLUGIN_ID}"],
706
+ "OpenClaw config cleanup",
707
+ )
708
+ if paths.root.exists():
709
+ shutil.rmtree(paths.root)
710
+
711
+
712
+ def connect_openclaw(
713
+ *,
714
+ link_url: str,
715
+ paths: InstallPaths,
716
+ runner: Runner = _run,
717
+ trust: dict[str, object] | None = None,
718
+ package_version: str | None = None,
719
+ http: httpx.Client | None = None,
720
+ ) -> None:
721
+ from datetime import UTC, datetime, timedelta
722
+ from urllib.parse import parse_qs, urlsplit, urlunsplit
723
+
724
+ link_url = _https_url(_validate_secret(link_url.strip()), "Cloud one-time link")
725
+ installed_package_version = package_version or _package_version()
726
+ parsed = urlsplit(link_url)
727
+ try:
728
+ query = parse_qs(parsed.query, strict_parsing=True)
729
+ except ValueError:
730
+ raise InstallError("Cloud one-time link context is invalid") from None
731
+ if set(query) != {"adapter_context"} or len(query["adapter_context"]) != 1:
732
+ raise InstallError("Cloud one-time link context is invalid")
733
+ base_path = parsed.path.rstrip("/")
734
+ if not re.fullmatch(r".*/onboard/[A-Za-z0-9_-]{20,128}", base_path):
735
+ raise InstallError("Cloud one-time link path is invalid")
736
+ manifest_url = urlunsplit(
737
+ (parsed.scheme, parsed.netloc, base_path + "/manifest/v2", parsed.query, "")
738
+ )
739
+ version = _detect_openclaw(runner)
740
+ try:
741
+ negotiate_host_target(
742
+ "openclaw",
743
+ version,
744
+ {"capture", "recall", "reload", "rollback", "rotate", "revoke"},
745
+ )
746
+ except HostTargetError as exc:
747
+ raise InstallError(str(exc)) from None
748
+ client = http or httpx.Client(timeout=15, follow_redirects=False)
749
+ owns_client = http is None
750
+ receipt: str | None = None
751
+ compensation_url: str | None = None
752
+ try:
753
+ try:
754
+ response = client.get(manifest_url, headers={"Accept": "application/json"})
755
+ except httpx.HTTPError as exc:
756
+ raise InstallError(f"Cloud manifest request failed ({type(exc).__name__})") from None
757
+ if response.status_code != 200:
758
+ raise InstallError(f"Cloud manifest request was rejected ({response.status_code})")
759
+ try:
760
+ manifest_payload = response.json()
761
+ except ValueError:
762
+ raise InstallError("Cloud adapter manifest is malformed") from None
763
+ manifest = _verify_manifest(
764
+ manifest_payload,
765
+ link_url=link_url,
766
+ openclaw_version=version,
767
+ trust=trust,
768
+ package_version=installed_package_version,
769
+ )
770
+ compensation_url = manifest.compensation_url
771
+ redemption = {
772
+ "agent_name": "Native OpenClaw",
773
+ "brain_id": manifest.identity["brain_id"],
774
+ "capabilities": manifest.capabilities,
775
+ "manifest_id": manifest.manifest_id,
776
+ "manifest_version": manifest.manifest_version,
777
+ "request_id": manifest.identity["request_id"],
778
+ "workspace_id": manifest.identity["workspace_id"],
779
+ }
780
+ try:
781
+ response = client.post(
782
+ manifest.redemption_url,
783
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
784
+ json=redemption,
785
+ )
786
+ except httpx.HTTPError as exc:
787
+ raise InstallError(f"Cloud redemption failed ({type(exc).__name__})") from None
788
+ if response.status_code != 201:
789
+ raise InstallError(f"Cloud redemption was rejected ({response.status_code})")
790
+ try:
791
+ redeemed = response.json()
792
+ except ValueError:
793
+ raise InstallError("Cloud redemption response is malformed") from None
794
+ if not isinstance(redeemed, dict):
795
+ raise InstallError("Cloud redemption response is malformed")
796
+ identity = redeemed.get("identity")
797
+ compensation = redeemed.get("compensation")
798
+ if isinstance(compensation, dict) and isinstance(compensation.get("receipt"), str):
799
+ candidate_receipt = compensation["receipt"]
800
+ if candidate_receipt and not any(char in candidate_receipt for char in "\r\n\0"):
801
+ receipt = candidate_receipt
802
+ credential = redeemed.get("credential")
803
+ expected_identity = {
804
+ "brain_id": manifest.identity["brain_id"],
805
+ "request_id": manifest.identity["request_id"],
806
+ "workspace_id": manifest.identity["workspace_id"],
807
+ }
808
+ if (
809
+ set(redeemed)
810
+ != {
811
+ "capabilities",
812
+ "compensation",
813
+ "credential",
814
+ "credential_notice",
815
+ "identity",
816
+ "manifest_version",
817
+ "rest_url",
818
+ }
819
+ or not isinstance(identity, dict)
820
+ or set(identity)
821
+ != {
822
+ "brain_id",
823
+ "connection_id",
824
+ "credential_id",
825
+ "request_id",
826
+ "workspace_id",
827
+ }
828
+ or any(identity.get(key) != value for key, value in expected_identity.items())
829
+ or not all(
830
+ isinstance(identity.get(key), str) and ID_PATTERN.fullmatch(identity[key])
831
+ for key in ("connection_id", "credential_id")
832
+ )
833
+ or redeemed.get("manifest_version") != manifest.manifest_version
834
+ or redeemed.get("rest_url") != manifest.rest_url
835
+ or redeemed.get("capabilities") != manifest.capabilities
836
+ or redeemed.get("credential_notice")
837
+ != "Shown once; store only in the Albert-owned credential file."
838
+ or not isinstance(credential, str)
839
+ or not credential
840
+ or any(character in credential for character in "\r\n\0")
841
+ or not isinstance(compensation, dict)
842
+ or set(compensation) != {"expires_at", "receipt"}
843
+ or receipt is None
844
+ or not isinstance(compensation.get("expires_at"), str)
845
+ ):
846
+ raise InstallError("Cloud redemption response identity does not match")
847
+ try:
848
+ compensation_expires = datetime.fromisoformat(
849
+ compensation["expires_at"].replace("Z", "+00:00")
850
+ )
851
+ except ValueError:
852
+ raise InstallError("Cloud compensation receipt lifetime is malformed") from None
853
+ now = datetime.now(UTC)
854
+ if (
855
+ compensation_expires.utcoffset() != timedelta(0)
856
+ or compensation_expires <= now
857
+ or compensation_expires - now > timedelta(minutes=11)
858
+ ):
859
+ raise InstallError("Cloud compensation receipt lifetime is invalid")
860
+ cloud_metadata = {
861
+ "brain_id": identity["brain_id"],
862
+ "connection_id": identity["connection_id"],
863
+ "credential_id": identity["credential_id"],
864
+ "manifest_id": manifest.manifest_id,
865
+ "manifest_version": manifest.manifest_version,
866
+ "request_id": identity["request_id"],
867
+ "rest_url": manifest.rest_url,
868
+ "workspace_id": identity["workspace_id"],
869
+ }
870
+ install_openclaw(
871
+ api_url=manifest.rest_url,
872
+ token=credential,
873
+ workspace_id=manifest.identity["workspace_id"],
874
+ paths=paths,
875
+ runner=runner,
876
+ cloud_metadata=cloud_metadata,
877
+ package_version=installed_package_version,
878
+ )
879
+ except Exception as connect_exc:
880
+ if receipt is not None and compensation_url is not None:
881
+ try:
882
+ compensated = client.post(
883
+ compensation_url,
884
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
885
+ json={"receipt": receipt},
886
+ )
887
+ if compensated.status_code != 200:
888
+ raise InstallError(
889
+ f"credential compensation was rejected ({compensated.status_code})"
890
+ )
891
+ try:
892
+ compensation_result = compensated.json()
893
+ except ValueError:
894
+ raise InstallError("credential compensation response is malformed") from None
895
+ expected_compensation = {
896
+ "connection_id": identity.get("connection_id")
897
+ if isinstance(identity, dict)
898
+ else None,
899
+ "credential_id": identity.get("credential_id")
900
+ if isinstance(identity, dict)
901
+ else None,
902
+ "revoked": True,
903
+ }
904
+ if compensation_result != expected_compensation:
905
+ raise InstallError("credential compensation response did not match")
906
+ except (httpx.HTTPError, InstallError) as compensation_exc:
907
+ raise InstallError(
908
+ f"{connect_exc}; replacement credential compensation failed "
909
+ f"({type(compensation_exc).__name__}); revoke the failed connection in Cloud"
910
+ ) from connect_exc
911
+ if isinstance(connect_exc, InstallError):
912
+ raise
913
+ raise InstallError(f"connection failed safely ({type(connect_exc).__name__})") from None
914
+ finally:
915
+ if owns_client:
916
+ client.close()
917
+
918
+
919
+ def _default_paths() -> InstallPaths:
920
+ return InstallPaths(Path("~/.openclaw/albert").expanduser())
921
+
922
+
923
+ @app.command("connect")
924
+ def connect_command(
925
+ link_stdin: bool = typer.Option(
926
+ False,
927
+ "--link-stdin",
928
+ help="Read the one-time link from private standard input.",
929
+ ),
930
+ link_fd: int | None = typer.Option(
931
+ None,
932
+ "--link-fd",
933
+ min=0,
934
+ help="Read the one-time link from an already-open private file descriptor.",
935
+ ),
936
+ ) -> None:
937
+ """Privately redeem a Cloud one-time link and connect OpenClaw."""
938
+ try:
939
+ if link_stdin and link_fd is not None:
940
+ raise InstallError("choose only one private link input mode")
941
+ if link_fd is not None:
942
+ with os.fdopen(os.dup(link_fd), encoding="utf-8") as stream:
943
+ link = stream.read(8193)
944
+ elif link_stdin:
945
+ link = sys.stdin.read(8193)
946
+ else:
947
+ link = getpass.getpass("Albert Cloud one-time link: ")
948
+ if len(link) > 8192:
949
+ raise InstallError("Cloud one-time link is too long")
950
+ connect_openclaw(link_url=link.strip(), paths=_default_paths())
951
+ typer.echo("Albert Memory connected; reload, live capture, and fresh recall passed.")
952
+ except (InstallError, OSError) as exc:
953
+ typer.echo(f"Connect failed safely: {exc}", err=True)
954
+ raise typer.Exit(1) from None
955
+
956
+
957
+ @app.command("install")
958
+ def install_command(
959
+ api_url: str = typer.Option(..., "--api-url", help="Direct self-hosted or managed Brain URL."),
960
+ workspace_id: str | None = typer.Option(None, "--workspace-id"),
961
+ ) -> None:
962
+ """Install the first ready adapter target (OpenClaw)."""
963
+ try:
964
+ token = getpass.getpass("Albert capture token: ")
965
+ install_openclaw(
966
+ api_url=api_url,
967
+ token=token,
968
+ workspace_id=workspace_id,
969
+ paths=_default_paths(),
970
+ )
971
+ typer.echo("Albert Memory installed for OpenClaw; live capture and recall passed.")
972
+ except InstallError as exc:
973
+ typer.echo(f"Install failed safely: {exc}", err=True)
974
+ raise typer.Exit(1) from None
975
+
976
+
977
+ @app.command("doctor")
978
+ def doctor_command(smoke: bool = typer.Option(False, "--smoke")) -> None:
979
+ """Verify the installed host, configuration, Brain, and optional live smoke flow."""
980
+ try:
981
+ doctor_openclaw(_default_paths(), smoke=smoke)
982
+ typer.echo("Albert Memory doctor passed.")
983
+ except InstallError as exc:
984
+ typer.echo(f"Doctor failed: {exc}", err=True)
985
+ raise typer.Exit(1) from None
986
+
987
+
988
+ @app.command("uninstall")
989
+ def uninstall_command() -> None:
990
+ """Remove only Albert-owned OpenClaw configuration and files."""
991
+ try:
992
+ uninstall_openclaw(_default_paths())
993
+ typer.echo("Albert Memory removed; non-Albert OpenClaw settings were preserved.")
994
+ except InstallError as exc:
995
+ typer.echo(f"Uninstall failed safely: {exc}", err=True)
996
+ raise typer.Exit(1) from None
997
+
998
+
999
+ if __name__ == "__main__":
1000
+ app()