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 +19 -0
- forget/cli.py +183 -0
- forget/consolidation.py +222 -0
- forget/crypto.py +239 -0
- forget/db.py +1382 -0
- forget/hybrid_workspace.py +748 -0
- forget/importers/__init__.py +0 -0
- forget/importers/git.py +273 -0
- forget/mcp.py +1293 -0
- forget/memory_engine.py +782 -0
- forget/policy_dataset.py +140 -0
- forget/ports.py +40 -0
- forget/preflight.py +231 -0
- forget/provider_matrix.py +417 -0
- forget/provider_runtime.py +1010 -0
- forget/providers.py +1351 -0
- forget/server.py +322 -0
- forget/store.py +16602 -0
- forget/utils.py +161 -0
- forget/vector_adapters.py +3737 -0
- forget_ai-0.2.0.dist-info/METADATA +387 -0
- forget_ai-0.2.0.dist-info/RECORD +25 -0
- forget_ai-0.2.0.dist-info/WHEEL +4 -0
- forget_ai-0.2.0.dist-info/entry_points.txt +2 -0
- forget_ai-0.2.0.dist-info/licenses/LICENSE +202 -0
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()
|
forget/consolidation.py
ADDED
|
@@ -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
|