forget-ai 0.2.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.
forget/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """forget — memory for your AI. It forgets the junk, keeps what matters.
2
+
3
+ The engine stores durable facts, retrieves them newest-first, retires stale
4
+ ones non-destructively (supersede), and runs a verification loop so the
5
+ memory keeps proving itself. The secret is the forgetting: an observation
6
+ gate decides what is worth keeping at all.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ # Environment compatibility shim: the engine historically reads MEM1_*
15
+ # variables. FORGET_* is the public spelling; either works — FORGET_* fills
16
+ # in only where the MEM1_* twin is unset.
17
+ for _key, _value in list(os.environ.items()):
18
+ if _key.startswith("FORGET_"):
19
+ os.environ.setdefault("MEM1_" + _key[len("FORGET_"):], _value)
forget/cli.py ADDED
@@ -0,0 +1,183 @@
1
+ """forget-server — run and manage the local Forget server.
2
+
3
+ The beta funnel's silent killer: a user installs, connects, reboots — and
4
+ the server is gone. Hooks are fail-open by design, so memory just quietly
5
+ stops arriving. The fix is a real lifecycle: `forget-server run` for the
6
+ foreground, `forget-server install-service` for a login service (launchd on
7
+ macOS, systemd --user on Linux), `status` to see what's true.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import socket
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ DEFAULT_HOST = "127.0.0.1"
20
+ DEFAULT_PORT = 8000
21
+ SERVICE_LABEL = "ai.forget.server"
22
+
23
+
24
+ def forget_home() -> Path:
25
+ return Path(os.environ.get("FORGET_HOME", Path.home() / ".forget"))
26
+
27
+
28
+ def db_path() -> Path:
29
+ return Path(os.environ.get("MEM1_DB_PATH", forget_home() / "forget.sqlite3"))
30
+
31
+
32
+ def log_path() -> Path:
33
+ return forget_home() / "server.log"
34
+
35
+
36
+ def launchd_plist(python: str, host: str, port: int) -> str:
37
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
38
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
39
+ <plist version="1.0">
40
+ <dict>
41
+ <key>Label</key><string>{SERVICE_LABEL}</string>
42
+ <key>ProgramArguments</key>
43
+ <array>
44
+ <string>{python}</string>
45
+ <string>-m</string><string>uvicorn</string>
46
+ <string>forget.server:app</string>
47
+ <string>--host</string><string>{host}</string>
48
+ <string>--port</string><string>{port}</string>
49
+ </array>
50
+ <key>EnvironmentVariables</key>
51
+ <dict><key>MEM1_DB_PATH</key><string>{db_path()}</string></dict>
52
+ <key>RunAtLoad</key><true/>
53
+ <key>KeepAlive</key><true/>
54
+ <key>StandardOutPath</key><string>{log_path()}</string>
55
+ <key>StandardErrorPath</key><string>{log_path()}</string>
56
+ </dict>
57
+ </plist>
58
+ """
59
+
60
+
61
+ def systemd_unit(python: str, host: str, port: int) -> str:
62
+ return f"""[Unit]
63
+ Description=Forget local memory server
64
+
65
+ [Service]
66
+ Environment=MEM1_DB_PATH={db_path()}
67
+ ExecStart={python} -m uvicorn forget.server:app --host {host} --port {port}
68
+ Restart=always
69
+ RestartSec=2
70
+
71
+ [Install]
72
+ WantedBy=default.target
73
+ """
74
+
75
+
76
+ def _port_open(host: str, port: int) -> bool:
77
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
78
+ sock.settimeout(0.6)
79
+ return sock.connect_ex((host, port)) == 0
80
+
81
+
82
+ def _require_uvicorn() -> None:
83
+ try:
84
+ import uvicorn # noqa: F401
85
+ except ImportError:
86
+ sys.exit("uvicorn is not installed. Run: pip install 'forget-ai[server]'")
87
+
88
+
89
+ def cmd_run(args: argparse.Namespace) -> None:
90
+ _require_uvicorn()
91
+ import uvicorn
92
+
93
+ forget_home().mkdir(parents=True, exist_ok=True)
94
+ os.environ.setdefault("MEM1_DB_PATH", str(db_path()))
95
+ print(f"forget-server: http://{args.host}:{args.port} (db: {db_path()})")
96
+ uvicorn.run("forget.server:app", host=args.host, port=args.port)
97
+
98
+
99
+ def _launchd_paths() -> tuple[Path, str]:
100
+ plist = Path.home() / "Library" / "LaunchAgents" / f"{SERVICE_LABEL}.plist"
101
+ domain = f"gui/{os.getuid()}"
102
+ return plist, domain
103
+
104
+
105
+ def cmd_install_service(args: argparse.Namespace) -> None:
106
+ _require_uvicorn()
107
+ forget_home().mkdir(parents=True, exist_ok=True)
108
+ python = sys.executable
109
+ if sys.platform == "darwin":
110
+ plist, domain = _launchd_paths()
111
+ plist.parent.mkdir(parents=True, exist_ok=True)
112
+ subprocess.run(["launchctl", "bootout", f"{domain}/{SERVICE_LABEL}"],
113
+ capture_output=True)
114
+ plist.write_text(launchd_plist(python, args.host, args.port))
115
+ subprocess.run(["launchctl", "bootstrap", domain, str(plist)], check=True)
116
+ print(f"installed launchd service {SERVICE_LABEL}\n plist: {plist}\n logs: {log_path()}")
117
+ elif sys.platform.startswith("linux"):
118
+ unit = Path.home() / ".config" / "systemd" / "user" / "forget-server.service"
119
+ unit.parent.mkdir(parents=True, exist_ok=True)
120
+ unit.write_text(systemd_unit(python, args.host, args.port))
121
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
122
+ subprocess.run(["systemctl", "--user", "enable", "--now", "forget-server.service"], check=True)
123
+ print(f"installed systemd user service forget-server\n unit: {unit}\n logs: journalctl --user -u forget-server")
124
+ else:
125
+ sys.exit("install-service supports macOS (launchd) and Linux (systemd --user). "
126
+ "On other platforms run `forget-server run` in a terminal.")
127
+
128
+
129
+ def cmd_uninstall_service(_args: argparse.Namespace) -> None:
130
+ if sys.platform == "darwin":
131
+ plist, domain = _launchd_paths()
132
+ subprocess.run(["launchctl", "bootout", f"{domain}/{SERVICE_LABEL}"],
133
+ capture_output=True)
134
+ if plist.exists():
135
+ plist.unlink()
136
+ print(f"removed launchd service {SERVICE_LABEL}")
137
+ elif sys.platform.startswith("linux"):
138
+ subprocess.run(["systemctl", "--user", "disable", "--now", "forget-server.service"],
139
+ capture_output=True)
140
+ unit = Path.home() / ".config" / "systemd" / "user" / "forget-server.service"
141
+ if unit.exists():
142
+ unit.unlink()
143
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
144
+ print("removed systemd user service forget-server")
145
+ else:
146
+ sys.exit("nothing to uninstall on this platform")
147
+
148
+
149
+ def cmd_status(args: argparse.Namespace) -> None:
150
+ listening = _port_open(args.host, args.port)
151
+ print(f"server: {'listening' if listening else 'not listening'} on {args.host}:{args.port}")
152
+ print(f"db: {db_path()}{'' if db_path().exists() else ' (not created yet)'}")
153
+ if sys.platform == "darwin":
154
+ plist, _ = _launchd_paths()
155
+ print(f"service: {'installed' if plist.exists() else 'not installed'} ({plist})")
156
+ elif sys.platform.startswith("linux"):
157
+ unit = Path.home() / ".config" / "systemd" / "user" / "forget-server.service"
158
+ print(f"service: {'installed' if unit.exists() else 'not installed'} ({unit})")
159
+ if not listening:
160
+ sys.exit(1)
161
+
162
+
163
+ def main(argv: list[str] | None = None) -> None:
164
+ shared = argparse.ArgumentParser(add_help=False)
165
+ shared.add_argument("--host", default=DEFAULT_HOST)
166
+ shared.add_argument("--port", type=int, default=DEFAULT_PORT)
167
+ parser = argparse.ArgumentParser(prog="forget-server", parents=[shared],
168
+ description="Run and manage the local Forget server.")
169
+ sub = parser.add_subparsers(dest="command")
170
+ sub.add_parser("run", help="run the server in the foreground", parents=[shared])
171
+ sub.add_parser("install-service", help="install a login service (launchd/systemd)", parents=[shared])
172
+ sub.add_parser("uninstall-service", help="remove the login service", parents=[shared])
173
+ sub.add_parser("status", help="show server and service state", parents=[shared])
174
+ args = parser.parse_args(argv)
175
+ command = args.command or "run"
176
+ {"run": cmd_run,
177
+ "install-service": cmd_install_service,
178
+ "uninstall-service": cmd_uninstall_service,
179
+ "status": cmd_status}[command](args)
180
+
181
+
182
+ if __name__ == "__main__":
183
+ main()
@@ -0,0 +1,222 @@
1
+ """Consolidation worker — the verification loop as a product behavior.
2
+
3
+ The brand promise is memory that keeps itself fresh, but until now the loop
4
+ (stale-candidates → adjudication → supersede) only ran when an agent or a
5
+ benchmark runner drove it by hand. This worker runs it in the background,
6
+ per project, against entities with recent write activity:
7
+
8
+ 1. find entities that stored memories since the last cycle (overlap-safe)
9
+ 2. surface same-topic old/new pairs via stale_candidate_pairs (a hint
10
+ generator — precision tuned for triage, the adjudicator decides)
11
+ 3. adjudicate each pair batch with a small LLM; two supersede registers:
12
+ state replacement (moved cities, changed stack) and plan completion
13
+ (a newer memory records that the older todo/intention got done or
14
+ dropped — the dogfood pain this worker exists to remove)
15
+ 4. supersede the losers (non-destructive; search demotes, rows stay)
16
+
17
+ Fail-safe posture: no adjudicator (missing API key, bad response) means no
18
+ supersession — the worker never guesses. Everything is capped per cycle so
19
+ a pathological corpus cannot melt the server or the LLM bill.
20
+
21
+ Gates: project setting `consolidation_enabled` (dogfood-first, like
22
+ temporal_rerank), env kill switch MEM1_CONSOLIDATION=0.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ from typing import Any
30
+
31
+ import httpx
32
+
33
+ from .db import get_db
34
+ from .providers import get_project_settings
35
+
36
+ ADJUDICATE_SYSTEM_PROMPT = (
37
+ "You maintain a long-term memory store. You are given pairs of memories "
38
+ "about the same subject: an OLDER one and a NEWER one. For each pair, "
39
+ "decide whether the newer memory makes the older one stale. Mark "
40
+ "supersede=true in exactly two situations:\n"
41
+ "1. State replacement: both describe the same fact or state and the newer "
42
+ "one replaces or invalidates the older one (moved cities, changed stack, "
43
+ "condition resolved, decision reversed).\n"
44
+ "2. Plan completion: the older memory is a plan, todo, or intention and "
45
+ "the newer memory records that it was completed, shipped, or abandoned.\n"
46
+ "If they cover different topics, or both can be true at once, answer "
47
+ "false. When unsure, answer false.\n"
48
+ 'Reply with a JSON array only, one object per pair, in the same order: '
49
+ '[{"pair": <index>, "supersede": true|false}]'
50
+ )
51
+
52
+
53
+ def consolidation_env_enabled() -> bool:
54
+ return (os.getenv("MEM1_CONSOLIDATION") or "1").strip().lower() not in {"0", "false", "no"}
55
+
56
+
57
+ def _consolidation_params() -> dict[str, Any]:
58
+ return {
59
+ "model": os.getenv("MEM1_CONSOLIDATION_MODEL") or "gpt-4o-mini",
60
+ "base_url": (os.getenv("MEM1_CONSOLIDATION_BASE_URL") or "https://api.openai.com/v1").rstrip("/"),
61
+ "api_key": os.getenv("MEM1_CONSOLIDATION_API_KEY") or os.getenv("OPENAI_API_KEY") or "",
62
+ "max_entities": int(os.getenv("MEM1_CONSOLIDATION_MAX_ENTITIES", "20")),
63
+ "max_pairs_per_entity": int(os.getenv("MEM1_CONSOLIDATION_MAX_PAIRS", "60")),
64
+ "max_supersedes_per_cycle": int(os.getenv("MEM1_CONSOLIDATION_MAX_SUPERSEDES", "40")),
65
+ "activity_window_hours": float(os.getenv("MEM1_CONSOLIDATION_WINDOW_HOURS", "26")),
66
+ # optional floor override for the pair inbox (embedding-scale dependent:
67
+ # production bge sits at ~0.80, the deterministic test embedding lower)
68
+ "min_similarity": os.getenv("MEM1_CONSOLIDATION_MIN_SIMILARITY"),
69
+ "batch_size": 12,
70
+ }
71
+
72
+
73
+ def _recently_active_entities(window_hours: float, limit: int) -> list[dict[str, str]]:
74
+ """(project, entity) pairs with new memories inside the window.
75
+
76
+ The window deliberately overlaps consecutive cycles: rerunning an entity
77
+ is idempotent (surfaced pairs whose loser is already superseded drop out
78
+ of the inbox), so a restart never loses work, only repeats cheap reads.
79
+ """
80
+ from datetime import datetime, timedelta, timezone
81
+
82
+ cutoff_iso = (datetime.now(timezone.utc) - timedelta(hours=window_hours)).strftime("%Y-%m-%dT%H:%M:%SZ")
83
+ with get_db() as conn:
84
+ rows = conn.execute(
85
+ """
86
+ SELECT project_id, user_id, agent_id, MAX(created_at) AS latest
87
+ FROM memories
88
+ WHERE deleted = 0 AND created_at > ?
89
+ AND (user_id IS NOT NULL AND user_id != '' OR agent_id IS NOT NULL AND agent_id != '')
90
+ GROUP BY project_id, user_id, agent_id
91
+ ORDER BY latest DESC
92
+ """,
93
+ (cutoff_iso,),
94
+ ).fetchall()
95
+ entities: list[dict[str, str]] = []
96
+ for row in rows[: max(limit, 0)]:
97
+ field = "user_id" if row["user_id"] else "agent_id"
98
+ entities.append({"project_id": row["project_id"], "field": field, "value": row[field]})
99
+ return entities
100
+
101
+
102
+ def _adjudicate_batch(pairs: list[dict[str, Any]], params: dict[str, Any]) -> list[bool]:
103
+ """One LLM call per batch; any failure refuses the whole batch."""
104
+ lines = []
105
+ for index, pair in enumerate(pairs):
106
+ older, newer = pair.get("older") or {}, pair.get("newer") or {}
107
+ lines.append(
108
+ f"PAIR {index}:\n"
109
+ f" OLDER ({str(older.get('created_at'))[:10]}): {str(older.get('memory'))[:400]}\n"
110
+ f" NEWER ({str(newer.get('created_at'))[:10]}): {str(newer.get('memory'))[:400]}"
111
+ )
112
+ body = {
113
+ "model": params["model"],
114
+ "messages": [
115
+ {"role": "system", "content": ADJUDICATE_SYSTEM_PROMPT},
116
+ {"role": "user", "content": "\n\n".join(lines)},
117
+ ],
118
+ "temperature": 0,
119
+ }
120
+ try:
121
+ with httpx.Client(timeout=30) as client:
122
+ response = client.post(
123
+ f"{params['base_url']}/chat/completions",
124
+ headers={"Authorization": f"Bearer {params['api_key']}", "Content-Type": "application/json"},
125
+ json=body,
126
+ )
127
+ response.raise_for_status()
128
+ content = str(response.json()["choices"][0]["message"]["content"]).strip()
129
+ if content.startswith("```"):
130
+ content = content.strip("`")
131
+ array = json.loads(content[content.index("[") : content.rindex("]") + 1])
132
+ verdicts = [False] * len(pairs)
133
+ for entry in array:
134
+ index = int(entry.get("pair", -1))
135
+ if 0 <= index < len(pairs):
136
+ verdicts[index] = bool(entry.get("supersede"))
137
+ return verdicts
138
+ except Exception:
139
+ return [False] * len(pairs)
140
+
141
+
142
+ # Pairs already adjudicated "keep both" this process lifetime. The inbox
143
+ # resurfaces the same stable pairs every cycle; without this, the worker
144
+ # re-buys the same LLM verdicts every 30 minutes. Process-lifetime is enough:
145
+ # a restart re-judges once, and superseded pairs leave the inbox on their own.
146
+ _adjudicated_keep: set[tuple[str, str]] = set()
147
+
148
+
149
+ def consolidation_cycle() -> dict[str, Any]:
150
+ """One pass over recently-active entities. Returns a cycle report."""
151
+ report: dict[str, Any] = {"entities": 0, "pairs": 0, "superseded": 0, "skipped_projects": 0}
152
+ if not consolidation_env_enabled():
153
+ report["disabled"] = "env"
154
+ return report
155
+ params = _consolidation_params()
156
+ if not params["api_key"]:
157
+ # no adjudicator → no judgments → no supersession; stay silent but honest
158
+ report["disabled"] = "no_api_key"
159
+ return report
160
+
161
+ # imported here: store imports providers, and this module rides on store's
162
+ # public operations — a top-level import would create a cycle
163
+ from .store import stale_candidate_pairs, supersede_memory
164
+
165
+ settings_cache: dict[str, bool] = {}
166
+ supersedes_left = params["max_supersedes_per_cycle"]
167
+ for entity in _recently_active_entities(params["activity_window_hours"], params["max_entities"]):
168
+ project_id = entity["project_id"]
169
+ if project_id not in settings_cache:
170
+ settings_cache[project_id] = bool(get_project_settings(project_id).get("consolidation_enabled"))
171
+ if not settings_cache[project_id]:
172
+ report["skipped_projects"] += 1
173
+ continue
174
+ min_days = get_project_settings(project_id).get("temporal_min_days")
175
+ payload: dict[str, Any] = {
176
+ "filters": {entity["field"]: entity["value"]},
177
+ "top_n": params["max_pairs_per_entity"],
178
+ "project_id": project_id,
179
+ }
180
+ if min_days is not None:
181
+ payload["min_days"] = min_days
182
+ if params["min_similarity"] is not None:
183
+ payload["min_similarity"] = float(params["min_similarity"])
184
+ try:
185
+ pairs = (stale_candidate_pairs(payload, project_id=project_id) or {}).get("pairs") or []
186
+ except Exception:
187
+ continue
188
+ pairs = [
189
+ pair for pair in pairs
190
+ if ((pair.get("older") or {}).get("id"), (pair.get("newer") or {}).get("id")) not in _adjudicated_keep
191
+ ]
192
+ report["entities"] += 1
193
+ report["pairs"] += len(pairs)
194
+ for start in range(0, len(pairs), params["batch_size"]):
195
+ if supersedes_left <= 0:
196
+ break
197
+ batch = pairs[start : start + params["batch_size"]]
198
+ verdicts = _adjudicate_batch(batch, params)
199
+ for pair, verdict in zip(batch, verdicts):
200
+ pair_key = ((pair.get("older") or {}).get("id"), (pair.get("newer") or {}).get("id"))
201
+ if not verdict:
202
+ _adjudicated_keep.add(pair_key)
203
+ continue
204
+ if supersedes_left <= 0:
205
+ continue
206
+ older, newer = pair.get("older") or {}, pair.get("newer") or {}
207
+ if not older.get("id") or not newer.get("id"):
208
+ continue
209
+ try:
210
+ supersede_memory(
211
+ older["id"],
212
+ {
213
+ "superseded_by": newer["id"],
214
+ "reason": "consolidation worker: newer state or completion supersedes",
215
+ },
216
+ project_id=project_id,
217
+ )
218
+ report["superseded"] += 1
219
+ supersedes_left -= 1
220
+ except Exception:
221
+ continue
222
+ return report
forget/crypto.py ADDED
@@ -0,0 +1,239 @@
1
+ """E2EE vault primitives: keys, wrapping, record sealing, recovery codes.
2
+
3
+ Pure functions over libsodium (PyNaCl); no I/O and no key custody here —
4
+ see docs/vault-design.md for the key hierarchy this implements and
5
+ forget/keyring.py (future) for storage. Nothing in this module invents a
6
+ cryptographic construction: every operation maps 1:1 onto a libsodium
7
+ primitive.
8
+
9
+ Requires the optional dependency: pip install forget-ai[vault]
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import secrets
15
+ from dataclasses import dataclass
16
+
17
+ try:
18
+ from nacl.bindings import (
19
+ crypto_aead_xchacha20poly1305_ietf_decrypt,
20
+ crypto_aead_xchacha20poly1305_ietf_encrypt,
21
+ )
22
+ from nacl.exceptions import CryptoError
23
+ from nacl.public import PrivateKey, PublicKey, SealedBox
24
+ from nacl.pwhash import argon2id
25
+ from nacl.signing import SigningKey, VerifyKey
26
+ except ImportError as error: # pragma: no cover
27
+ raise ImportError(
28
+ "forget.crypto needs PyNaCl. Install it with: pip install forget-ai[vault]"
29
+ ) from error
30
+
31
+ KEY_BYTES = 32
32
+ NONCE_BYTES = 24
33
+
34
+ _RECOVERY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" # RFC 4648 base32
35
+ _RECOVERY_VERSION = "F1"
36
+ _RECOVERY_DATA_CHARS = 26 # 130 bits of entropy
37
+ _RECOVERY_CHECK_CHARS = 2
38
+
39
+
40
+ class VaultCryptoError(Exception):
41
+ """Decryption or verification failed: wrong key, tampered data, or
42
+ mismatched envelope (AAD)."""
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class DeviceKeys:
47
+ """One device's key material. Private halves belong in the OS keychain."""
48
+
49
+ signing_seed: bytes # Ed25519 seed (server-auth signing)
50
+ verify_key: bytes # Ed25519 public
51
+ exchange_private: bytes # X25519 private (master-key sealing)
52
+ exchange_public: bytes # X25519 public
53
+
54
+
55
+ # --- key generation -------------------------------------------------------
56
+
57
+ def generate_master_key() -> bytes:
58
+ return secrets.token_bytes(KEY_BYTES)
59
+
60
+
61
+ def generate_scope_key() -> bytes:
62
+ return secrets.token_bytes(KEY_BYTES)
63
+
64
+
65
+ def generate_device_keys() -> DeviceKeys:
66
+ signing = SigningKey.generate()
67
+ exchange = PrivateKey.generate()
68
+ return DeviceKeys(
69
+ signing_seed=bytes(signing),
70
+ verify_key=bytes(signing.verify_key),
71
+ exchange_private=bytes(exchange),
72
+ exchange_public=bytes(exchange.public_key),
73
+ )
74
+
75
+
76
+ # --- master key <-> device (sealed box) -----------------------------------
77
+
78
+ def seal_to_device(secret: bytes, exchange_public: bytes) -> bytes:
79
+ """Encrypt a secret so only the holder of the device private key can
80
+ read it. Anyone (including the server relaying enrollment) can produce
81
+ this; only the device can open it."""
82
+ return SealedBox(PublicKey(exchange_public)).encrypt(secret)
83
+
84
+
85
+ def open_from_device(sealed: bytes, exchange_private: bytes) -> bytes:
86
+ try:
87
+ return SealedBox(PrivateKey(exchange_private)).decrypt(sealed)
88
+ except CryptoError as error:
89
+ raise VaultCryptoError("sealed key does not open with this device key") from error
90
+
91
+
92
+ # --- scope keys <-> master key (AEAD wrap) --------------------------------
93
+
94
+ def _aead_encrypt(plaintext: bytes, key: bytes, aad: bytes) -> bytes:
95
+ nonce = secrets.token_bytes(NONCE_BYTES)
96
+ ciphertext = crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, aad, nonce, key)
97
+ return nonce + ciphertext
98
+
99
+
100
+ def _aead_decrypt(blob: bytes, key: bytes, aad: bytes, what: str) -> bytes:
101
+ if len(blob) <= NONCE_BYTES:
102
+ raise VaultCryptoError(f"{what}: blob too short")
103
+ try:
104
+ return crypto_aead_xchacha20poly1305_ietf_decrypt(
105
+ blob[NONCE_BYTES:], aad, blob[:NONCE_BYTES], key
106
+ )
107
+ except CryptoError as error:
108
+ raise VaultCryptoError(f"{what}: wrong key, tampered data, or mismatched context") from error
109
+
110
+
111
+ def wrap_key(key_to_wrap: bytes, master_key: bytes, *, context: str) -> bytes:
112
+ """Wrap a scope key under the master key. `context` (e.g. "scope:<uuid>")
113
+ binds the wrap to its purpose so a wrapped key cannot be replayed as a
114
+ different scope's key."""
115
+ return _aead_encrypt(key_to_wrap, master_key, context.encode())
116
+
117
+
118
+ def unwrap_key(wrapped: bytes, master_key: bytes, *, context: str) -> bytes:
119
+ return _aead_decrypt(wrapped, master_key, context.encode(), "unwrap_key")
120
+
121
+
122
+ # --- record sealing --------------------------------------------------------
123
+
124
+ def record_aad(record_id: str, scope_id: str, schema_ver: int, seq: int) -> bytes:
125
+ """Canonical associated data binding a ciphertext to its envelope row.
126
+ A server that swaps scope_id or replays a record at a different seq
127
+ produces a blob that fails authentication."""
128
+ for name, value in (("record_id", record_id), ("scope_id", scope_id)):
129
+ if "|" in value:
130
+ raise ValueError(f"{name} must not contain '|'")
131
+ return f"{record_id}|{scope_id}|{schema_ver}|{seq}".encode()
132
+
133
+
134
+ def seal_record(
135
+ plaintext: bytes,
136
+ scope_key: bytes,
137
+ *,
138
+ record_id: str,
139
+ scope_id: str,
140
+ schema_ver: int,
141
+ seq: int,
142
+ ) -> bytes:
143
+ """Encrypt one memory record (text, embedding, metadata — already
144
+ serialized) under its scope key. Returns nonce-prefixed ciphertext."""
145
+ return _aead_encrypt(
146
+ plaintext, scope_key, record_aad(record_id, scope_id, schema_ver, seq)
147
+ )
148
+
149
+
150
+ def open_record(
151
+ blob: bytes,
152
+ scope_key: bytes,
153
+ *,
154
+ record_id: str,
155
+ scope_id: str,
156
+ schema_ver: int,
157
+ seq: int,
158
+ ) -> bytes:
159
+ return _aead_decrypt(
160
+ blob,
161
+ scope_key,
162
+ record_aad(record_id, scope_id, schema_ver, seq),
163
+ "open_record",
164
+ )
165
+
166
+
167
+ # --- recovery code ---------------------------------------------------------
168
+
169
+ def _recovery_checksum(data: str) -> str:
170
+ digest = hashlib.blake2b(data.encode(), digest_size=4).digest()
171
+ value = int.from_bytes(digest, "big")
172
+ first = _RECOVERY_ALPHABET[value % 32]
173
+ second = _RECOVERY_ALPHABET[(value // 32) % 32]
174
+ return first + second
175
+
176
+
177
+ def normalize_recovery_code(code: str) -> str:
178
+ return code.replace("-", "").replace(" ", "").upper()
179
+
180
+
181
+ def generate_recovery_code() -> str:
182
+ """One-time recovery code (130 bits + checksum), shown once at vault
183
+ creation. Format: F1-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXX."""
184
+ data = "".join(secrets.choice(_RECOVERY_ALPHABET) for _ in range(_RECOVERY_DATA_CHARS))
185
+ raw = _RECOVERY_VERSION + data + _recovery_checksum(_RECOVERY_VERSION + data)
186
+ groups = [raw[i : i + 5] for i in range(0, len(raw), 5)]
187
+ return "-".join(groups)
188
+
189
+
190
+ def validate_recovery_code(code: str) -> bool:
191
+ raw = normalize_recovery_code(code)
192
+ expected_len = len(_RECOVERY_VERSION) + _RECOVERY_DATA_CHARS + _RECOVERY_CHECK_CHARS
193
+ if len(raw) != expected_len or not raw.startswith(_RECOVERY_VERSION):
194
+ return False
195
+ body, check = raw[:-_RECOVERY_CHECK_CHARS], raw[-_RECOVERY_CHECK_CHARS:]
196
+ if any(char not in _RECOVERY_ALPHABET for char in body[len(_RECOVERY_VERSION):]):
197
+ return False
198
+ return _recovery_checksum(body) == check
199
+
200
+
201
+ def derive_recovery_key(
202
+ code: str,
203
+ salt: bytes,
204
+ *,
205
+ opslimit: int = argon2id.OPSLIMIT_MODERATE,
206
+ memlimit: int = argon2id.MEMLIMIT_MODERATE,
207
+ ) -> bytes:
208
+ """Argon2id-derive the recovery key that wraps the master key. The salt
209
+ is stored server-side next to the recovery-wrapped master key; the code
210
+ itself never leaves the user."""
211
+ if not validate_recovery_code(code):
212
+ raise ValueError("malformed recovery code")
213
+ if len(salt) != argon2id.SALTBYTES:
214
+ raise ValueError(f"salt must be {argon2id.SALTBYTES} bytes")
215
+ return argon2id.kdf(
216
+ KEY_BYTES,
217
+ normalize_recovery_code(code).encode(),
218
+ salt,
219
+ opslimit=opslimit,
220
+ memlimit=memlimit,
221
+ )
222
+
223
+
224
+ def generate_recovery_salt() -> bytes:
225
+ return secrets.token_bytes(argon2id.SALTBYTES)
226
+
227
+
228
+ # --- server auth (Ed25519 challenge-response) ------------------------------
229
+
230
+ def sign_challenge(challenge: bytes, signing_seed: bytes) -> bytes:
231
+ return SigningKey(signing_seed).sign(challenge).signature
232
+
233
+
234
+ def verify_challenge(challenge: bytes, signature: bytes, verify_key: bytes) -> bool:
235
+ try:
236
+ VerifyKey(verify_key).verify(challenge, signature)
237
+ return True
238
+ except CryptoError:
239
+ return False