forgetted 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.
forgetted/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """
2
+ forgetted — Selective memory governance for AI agents.
3
+
4
+ Branch the timeline, but never merge back.
5
+
6
+ When triggered, forgetted:
7
+ 1. Checkpoints the current session context into a resumption file
8
+ 2. Blocks all persistence layers via registered adapters
9
+ 3. Self-cleans on exit (session logs, leaked memories)
10
+ 4. Enables seamless resume from the checkpoint in the next session
11
+
12
+ This is not incognito mode. This is a fork without consequence —
13
+ a memory architecture primitive that gives users control over
14
+ what becomes part of their agent's memory.
15
+
16
+ Author: Hermes Labs
17
+ License: Apache-2.0
18
+ """
19
+
20
+ from .adapters.base import PersistenceAdapter
21
+ from .adapters.file_write import FileWriteAdapter
22
+ from .checkpoint import create_checkpoint, load_checkpoint
23
+ from .cleaner import delete_session_log, find_session_log
24
+ from .guard import ForgetGuard
25
+ from .session import ForgetSession
26
+ from .trigger import TRIGGERS, is_forget_trigger
27
+
28
+ __version__ = "0.2.0"
29
+ __author__ = "Hermes Labs"
30
+ __all__ = [
31
+ "create_checkpoint",
32
+ "delete_session_log",
33
+ "FileWriteAdapter",
34
+ "find_session_log",
35
+ "ForgetGuard",
36
+ "ForgetSession",
37
+ "is_forget_trigger",
38
+ "load_checkpoint",
39
+ "PersistenceAdapter",
40
+ "TRIGGERS",
41
+ ]
@@ -0,0 +1,25 @@
1
+ """
2
+ forgetted.adapters — Persistence layer adapters.
3
+
4
+ Built-in adapters:
5
+ - FileWriteAdapter: blocks file writes via builtins.open patch
6
+ - Mem0Adapter: blocks mem0 add/update (requires mem0ai)
7
+
8
+ Custom adapters: subclass ``PersistenceAdapter`` from ``forgetted.adapters.base``.
9
+ """
10
+
11
+ from .base import PersistenceAdapter
12
+ from .file_write import FileWriteAdapter
13
+
14
+ __all__ = [
15
+ "PersistenceAdapter",
16
+ "FileWriteAdapter",
17
+ ]
18
+
19
+ # Optional adapters — import only if deps are available.
20
+ try:
21
+ from .mem0 import Mem0Adapter # noqa: F401
22
+
23
+ __all__.append("Mem0Adapter")
24
+ except ImportError:
25
+ pass
@@ -0,0 +1,72 @@
1
+ """
2
+ forgetted.adapters.base — Abstract base for persistence adapters.
3
+
4
+ Every persistence layer that forgetted can control implements this interface.
5
+ Adapters are registered with a ForgetSession, which calls disable/enable/cleanup
6
+ at the appropriate times.
7
+
8
+ To write a custom adapter::
9
+
10
+ from forgetted.adapters.base import PersistenceAdapter
11
+
12
+ class MyVectorDBAdapter(PersistenceAdapter):
13
+ name = "my-vector-db"
14
+
15
+ def disable(self):
16
+ self._client.pause_writes()
17
+ self._active = False
18
+
19
+ def enable(self):
20
+ self._client.resume_writes()
21
+ self._active = True
22
+
23
+ def cleanup(self):
24
+ self._client.delete_since(self._window_start)
25
+ """
26
+
27
+ from abc import ABC, abstractmethod
28
+
29
+
30
+ class PersistenceAdapter(ABC):
31
+ """Interface for a persistence layer that forgetted can control.
32
+
33
+ Subclasses must implement ``disable``, ``enable``, ``cleanup``,
34
+ and the ``name`` and ``is_active`` properties.
35
+ """
36
+
37
+ @property
38
+ @abstractmethod
39
+ def name(self) -> str:
40
+ """Human-readable identifier for this adapter (e.g., 'mem0', 'file-write')."""
41
+
42
+ @property
43
+ @abstractmethod
44
+ def is_active(self) -> bool:
45
+ """True when writes are being blocked (adapter is in disabled/forgetted state)."""
46
+
47
+ @abstractmethod
48
+ def disable(self) -> None:
49
+ """Block writes through this persistence layer.
50
+
51
+ Called when a forgetted window starts. Must be idempotent —
52
+ calling disable() twice should not error.
53
+ """
54
+
55
+ @abstractmethod
56
+ def enable(self) -> None:
57
+ """Restore normal write behavior.
58
+
59
+ Called when a forgetted window ends. Must be idempotent —
60
+ calling enable() twice should not error.
61
+ """
62
+
63
+ @abstractmethod
64
+ def cleanup(self) -> None:
65
+ """Remove any data that leaked through during the forgetted window.
66
+
67
+ Called after enable(). This is the post-window sweep — delete
68
+ any memories, embeddings, or logs that were written despite
69
+ the disable() call (e.g., by framework-level code).
70
+
71
+ Must be safe to call even if nothing leaked (no-op in that case).
72
+ """
@@ -0,0 +1,53 @@
1
+ """
2
+ forgetted.adapters.file_write — File write blocking adapter.
3
+
4
+ Wraps the existing ForgetGuard (builtins.open monkey-patch) as a
5
+ PersistenceAdapter. This is the safety-net layer — it catches writes
6
+ that slip past higher-level adapters.
7
+ """
8
+
9
+ import logging
10
+ from typing import Optional
11
+
12
+ from ..guard import ForgetGuard
13
+ from .base import PersistenceAdapter
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class FileWriteAdapter(PersistenceAdapter):
19
+ """Adapter that blocks file writes to protected workspace paths.
20
+
21
+ Parameters
22
+ ----------
23
+ workspace_path : str
24
+ Absolute path to the agent workspace root.
25
+ extra_protected : set[str], optional
26
+ Additional relative paths or directory names to protect.
27
+ """
28
+
29
+ def __init__(self, workspace_path: str, extra_protected: Optional[set[str]] = None):
30
+ self._guard = ForgetGuard(workspace_path, extra_protected)
31
+
32
+ @property
33
+ def name(self) -> str:
34
+ return "file-write"
35
+
36
+ @property
37
+ def is_active(self) -> bool:
38
+ return self._guard.active
39
+
40
+ def disable(self) -> None:
41
+ self._guard.start()
42
+
43
+ def enable(self) -> None:
44
+ self._guard.stop()
45
+
46
+ def cleanup(self) -> None:
47
+ # No cleanup needed — writes were blocked, not captured.
48
+ pass
49
+
50
+ @property
51
+ def blocked_count(self) -> int:
52
+ """Number of write attempts blocked during the current/last window."""
53
+ return self._guard.blocked_count
@@ -0,0 +1,127 @@
1
+ """
2
+ forgetted.adapters.mem0 — mem0 semantic memory adapter.
3
+
4
+ Disables mem0 writes during a forgetted window by monkey-patching the
5
+ Memory instance's ``add`` and ``update`` methods. On cleanup, deletes
6
+ any memories added during the window (by timestamp comparison).
7
+
8
+ Requires: ``pip install mem0ai`` (optional dependency).
9
+ """
10
+
11
+ import logging
12
+ import time
13
+ from typing import Any
14
+
15
+ from .base import PersistenceAdapter
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class Mem0Adapter(PersistenceAdapter):
21
+ """Adapter for mem0 (semantic memory layer).
22
+
23
+ Parameters
24
+ ----------
25
+ memory : object
26
+ A mem0 ``Memory`` instance. The adapter patches its ``add``
27
+ and ``update`` methods during the forgetted window.
28
+ user_id : str, optional
29
+ mem0 user ID for scoped cleanup queries.
30
+
31
+ Example
32
+ -------
33
+ ::
34
+
35
+ from mem0 import Memory
36
+ from forgetted.adapters.mem0 import Mem0Adapter
37
+
38
+ m = Memory()
39
+ adapter = Mem0Adapter(m, user_id="roli")
40
+ """
41
+
42
+ def __init__(self, memory: Any, user_id: str = "default"):
43
+ self._memory = memory
44
+ self._user_id = user_id
45
+ self._original_add = None
46
+ self._original_update = None
47
+ self._active = False
48
+ self._window_start: float = 0
49
+
50
+ @property
51
+ def name(self) -> str:
52
+ return "mem0"
53
+
54
+ @property
55
+ def is_active(self) -> bool:
56
+ return self._active
57
+
58
+ def disable(self) -> None:
59
+ if self._active:
60
+ return
61
+ self._window_start = time.time()
62
+ self._original_add = self._memory.add
63
+ self._original_update = getattr(self._memory, "update", None)
64
+
65
+ def _noop_add(*args, **kwargs):
66
+ logger.debug("🫥 mem0 add blocked during forgetted window")
67
+ return {"results": [], "blocked_by": "forgetted"}
68
+
69
+ def _noop_update(*args, **kwargs):
70
+ logger.debug("🫥 mem0 update blocked during forgetted window")
71
+ return {"results": [], "blocked_by": "forgetted"}
72
+
73
+ self._memory.add = _noop_add
74
+ if self._original_update is not None:
75
+ self._memory.update = _noop_update
76
+
77
+ self._active = True
78
+ logger.info("🫥 mem0 adapter disabled — add/update blocked")
79
+
80
+ def enable(self) -> None:
81
+ if not self._active:
82
+ return
83
+ self._memory.add = self._original_add
84
+ if self._original_update is not None:
85
+ self._memory.update = self._original_update
86
+ self._original_add = None
87
+ self._original_update = None
88
+ self._active = False
89
+ logger.info("🫥 mem0 adapter enabled — normal writes restored")
90
+
91
+ def cleanup(self) -> None:
92
+ """Delete any memories that leaked through during the window.
93
+
94
+ Queries mem0 for memories created after ``_window_start`` and
95
+ deletes them. This catches writes made by framework code that
96
+ bypassed the patched methods.
97
+ """
98
+ if self._window_start == 0:
99
+ return
100
+
101
+ try:
102
+ # mem0's get_all returns memories with metadata including created_at
103
+ all_memories = self._memory.get_all(user_id=self._user_id)
104
+ if not all_memories:
105
+ return
106
+
107
+ # Handle both dict and list response formats
108
+ memories = all_memories if isinstance(all_memories, list) else all_memories.get("results", [])
109
+ deleted = 0
110
+ for mem in memories:
111
+ created = mem.get("created_at", 0)
112
+ # mem0 uses ISO timestamps or epoch — handle both
113
+ if isinstance(created, str):
114
+ continue # Skip string timestamps for now — timestamp comparison is fragile
115
+ if created >= self._window_start:
116
+ try:
117
+ self._memory.delete(mem["id"])
118
+ deleted += 1
119
+ except Exception as exc:
120
+ logger.warning("🫥 Failed to delete mem0 memory %s: %s", mem.get("id"), exc)
121
+
122
+ if deleted:
123
+ logger.info("🫥 mem0 cleanup: deleted %d memories from forgetted window", deleted)
124
+ except Exception as exc:
125
+ logger.warning("🫥 mem0 cleanup failed (non-fatal): %s", exc)
126
+ finally:
127
+ self._window_start = 0
@@ -0,0 +1,82 @@
1
+ """
2
+ forgetted.checkpoint — Checkpoint creation and resume logic.
3
+
4
+ Before entering forgetted mode, the agent saves a compact summary of the
5
+ current session state. On the next normal session, the checkpoint is loaded
6
+ (providing continuity) and then deleted (single-use).
7
+
8
+ Checkpoint files live at ``<workspace>/memory/forgetted-checkpoint.md``.
9
+ """
10
+
11
+ import logging
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _CHECKPOINT_FILENAME = "forgetted-checkpoint.md"
19
+
20
+
21
+ def create_checkpoint(context_summary: str, workspace: str) -> Path:
22
+ """Write a compact resumption file before entering forgetted mode.
23
+
24
+ Parameters
25
+ ----------
26
+ context_summary : str
27
+ A concise summary of the current session context — open tasks,
28
+ last topic discussed, any pending decisions.
29
+ workspace : str
30
+ Absolute path to the agent workspace root.
31
+
32
+ Returns
33
+ -------
34
+ Path
35
+ Absolute path to the created checkpoint file.
36
+ """
37
+ memory_dir = Path(workspace) / "memory"
38
+ memory_dir.mkdir(parents=True, exist_ok=True)
39
+
40
+ checkpoint_path = memory_dir / _CHECKPOINT_FILENAME
41
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
42
+
43
+ content = (
44
+ f"# Forgetted Checkpoint\n"
45
+ f"*Created: {timestamp}*\n\n"
46
+ f"## Session Context\n"
47
+ f"{context_summary}\n\n"
48
+ f"## Instructions\n"
49
+ f"This checkpoint was created before a forgetted window. "
50
+ f"Resume from the context above. The forgetted conversation "
51
+ f"has been erased — do not attempt to recover it.\n"
52
+ )
53
+
54
+ checkpoint_path.write_text(content, encoding="utf-8")
55
+ logger.info("🫥 Checkpoint saved to %s", checkpoint_path)
56
+ return checkpoint_path
57
+
58
+
59
+ def load_checkpoint(workspace: str) -> Optional[str]:
60
+ """Read the checkpoint file if it exists, then delete it.
61
+
62
+ The checkpoint is single-use: once loaded, it's consumed.
63
+
64
+ Parameters
65
+ ----------
66
+ workspace : str
67
+ Absolute path to the agent workspace root.
68
+
69
+ Returns
70
+ -------
71
+ str or None
72
+ Checkpoint content, or None if no checkpoint exists.
73
+ """
74
+ checkpoint_path = Path(workspace) / "memory" / _CHECKPOINT_FILENAME
75
+
76
+ if not checkpoint_path.exists():
77
+ return None
78
+
79
+ content = checkpoint_path.read_text(encoding="utf-8")
80
+ checkpoint_path.unlink()
81
+ logger.info("🫥 Checkpoint loaded and consumed from %s", checkpoint_path)
82
+ return content
forgetted/cleaner.py ADDED
@@ -0,0 +1,88 @@
1
+ """
2
+ forgetted.cleaner — Session log finder and deleter.
3
+
4
+ After exiting forgetted mode, the session log covering the forgetted window
5
+ must be removed so no trace of the private conversation persists on disk.
6
+
7
+ Deletion is recoverable by default: uses ``send2trash`` if available,
8
+ otherwise renames the file with a ``.deleted`` suffix (can be manually
9
+ restored or purged later).
10
+ """
11
+
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def find_session_log(session_id: str, agents_dir: str) -> Optional[Path]:
20
+ """Locate the .jsonl session log for a given session ID.
21
+
22
+ Searches recursively under *agents_dir* for any ``.jsonl`` file whose
23
+ name contains the *session_id*.
24
+
25
+ Parameters
26
+ ----------
27
+ session_id : str
28
+ The session identifier to search for.
29
+ agents_dir : str
30
+ Root directory to search (e.g., ``~/.openclaw/agents/``).
31
+
32
+ Returns
33
+ -------
34
+ Path or None
35
+ Path to the matching session log, or None if not found.
36
+ """
37
+ root = Path(agents_dir)
38
+ if not root.exists():
39
+ return None
40
+
41
+ for jsonl_file in root.rglob("*.jsonl"):
42
+ if session_id in jsonl_file.stem:
43
+ logger.debug("🫥 Found session log: %s", jsonl_file)
44
+ return jsonl_file
45
+
46
+ return None
47
+
48
+
49
+ def delete_session_log(session_log: Path) -> bool:
50
+ """Remove a session log file safely (recoverable).
51
+
52
+ Attempts ``send2trash`` first (moves to OS trash). Falls back to
53
+ renaming with a ``.deleted`` suffix if send2trash is unavailable.
54
+
55
+ Parameters
56
+ ----------
57
+ session_log : Path
58
+ Path to the .jsonl session log to delete.
59
+
60
+ Returns
61
+ -------
62
+ bool
63
+ True if the file was successfully removed/renamed.
64
+ """
65
+ if not session_log.exists():
66
+ logger.warning("🫥 Session log not found: %s", session_log)
67
+ return False
68
+
69
+ # Try send2trash first — moves to OS trash (recoverable).
70
+ try:
71
+ from send2trash import send2trash
72
+ send2trash(str(session_log))
73
+ logger.info("🫥 Session log trashed: %s", session_log)
74
+ return True
75
+ except ImportError:
76
+ pass
77
+ except Exception as exc:
78
+ logger.warning("🫥 send2trash failed (%s), falling back to rename", exc)
79
+
80
+ # Fallback: rename with .deleted suffix (manually recoverable).
81
+ deleted_path = session_log.with_suffix(session_log.suffix + ".deleted")
82
+ try:
83
+ session_log.rename(deleted_path)
84
+ logger.info("🫥 Session log renamed to %s", deleted_path)
85
+ return True
86
+ except OSError as exc:
87
+ logger.error("🫥 Failed to delete session log: %s", exc)
88
+ return False
forgetted/guard.py ADDED
@@ -0,0 +1,161 @@
1
+ """
2
+ forgetted.guard — Write-blocking interceptor for forgetted mode.
3
+
4
+ Monkey-patches ``builtins.open`` while active so that any attempt to write
5
+ (modes 'w', 'a', 'x' and their binary variants) to protected paths inside
6
+ the workspace silently returns a no-op file handle. Reads are never blocked.
7
+
8
+ This is the core primitive: "this session can read from memory but cannot
9
+ write to it." A fork without consequence — the branch exists in context
10
+ but is never merged back into the agent's persistent state.
11
+
12
+ Protected paths:
13
+ - memory/ directory (daily logs, checkpoints)
14
+ - DELIVERABLES.md
15
+ - Any *.jsonl file (session logs)
16
+
17
+ Security model:
18
+ - Input: workspace path (trusted, set by caller)
19
+ - Patches builtins.open at process level — catches writes from any layer
20
+ - Returns no-op StringIO/BytesIO instead of raising — agent code doesn't crash
21
+ - Restores original open on stop() — no permanent side effects
22
+ """
23
+
24
+ import builtins
25
+ import io
26
+ import logging
27
+ import re
28
+ from pathlib import Path
29
+ from typing import Optional
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Modes that involve writing — we block these on protected paths.
34
+ _WRITE_MODES = re.compile(r"[wax+]", re.IGNORECASE)
35
+
36
+ # Default protected path patterns (relative to workspace root).
37
+ _DEFAULT_PROTECTED = {
38
+ "memory", # entire memory/ directory tree
39
+ "DELIVERABLES.md", # shared deliverables log
40
+ }
41
+
42
+ # File extensions that are always blocked inside the workspace.
43
+ _BLOCKED_EXTENSIONS = {".jsonl"}
44
+
45
+
46
+ class ForgetGuard:
47
+ """Context manager that blocks file writes to protected workspace paths.
48
+
49
+ Parameters
50
+ ----------
51
+ workspace_path : str
52
+ Absolute path to the agent workspace root.
53
+ extra_protected : set[str], optional
54
+ Additional relative paths or directory names to protect.
55
+
56
+ Usage
57
+ -----
58
+ ::
59
+
60
+ guard = ForgetGuard("/path/to/workspace")
61
+ guard.start()
62
+ # ... agent runs, writes to memory/ silently vanish ...
63
+ guard.stop()
64
+
65
+ Or as a context manager::
66
+
67
+ with ForgetGuard("/path/to/workspace"):
68
+ ...
69
+ """
70
+
71
+ def __init__(self, workspace_path: str, extra_protected: Optional[set[str]] = None):
72
+ self.workspace = Path(workspace_path).resolve()
73
+ self.protected = _DEFAULT_PROTECTED | (extra_protected or set())
74
+ self.active = False
75
+ self._original_open = None
76
+ self._blocked_count = 0
77
+
78
+ # -- public API ---------------------------------------------------------
79
+
80
+ def start(self):
81
+ """Activate write blocking. Patches ``builtins.open``."""
82
+ if self.active:
83
+ return
84
+ self._original_open = builtins.open
85
+ self._blocked_count = 0
86
+ builtins.open = self._patched_open # type: ignore[assignment]
87
+ self.active = True
88
+ logger.info("🫥 Forgetted guard active — writes to protected paths are blocked")
89
+
90
+ def stop(self):
91
+ """Deactivate write blocking. Restores original ``builtins.open``."""
92
+ if not self.active:
93
+ return
94
+ builtins.open = self._original_open # type: ignore[assignment]
95
+ self._original_open = None
96
+ self.active = False
97
+ logger.info("🫥 Forgetted guard stopped — %d write(s) blocked", self._blocked_count)
98
+
99
+ @property
100
+ def blocked_count(self) -> int:
101
+ """Number of write attempts blocked since guard was started."""
102
+ return self._blocked_count
103
+
104
+ # -- context manager ----------------------------------------------------
105
+
106
+ def __enter__(self):
107
+ self.start()
108
+ return self
109
+
110
+ def __exit__(self, exc_type, exc_val, exc_tb):
111
+ self.stop()
112
+ return False
113
+
114
+ # -- internal -----------------------------------------------------------
115
+
116
+ def _is_protected(self, filepath: Path) -> bool:
117
+ """Check if *filepath* falls under a protected path."""
118
+ try:
119
+ resolved = filepath.resolve()
120
+ except (OSError, ValueError):
121
+ return False
122
+
123
+ # Must be inside the workspace to be subject to protection.
124
+ try:
125
+ rel = resolved.relative_to(self.workspace)
126
+ except ValueError:
127
+ return False
128
+
129
+ # Check extension blocklist (e.g., .jsonl anywhere in workspace).
130
+ if resolved.suffix in _BLOCKED_EXTENSIONS:
131
+ return True
132
+
133
+ # Check protected directories and files.
134
+ rel_parts = rel.parts
135
+ for protected in self.protected:
136
+ # Direct filename match (e.g., "DELIVERABLES.md").
137
+ if rel_parts and rel_parts[-1] == protected:
138
+ return True
139
+ # Directory match (e.g., "memory" matches memory/anything).
140
+ if protected in rel_parts:
141
+ return True
142
+
143
+ return False
144
+
145
+ def _is_write_mode(self, mode: str) -> bool:
146
+ """Return True if *mode* involves writing."""
147
+ return bool(_WRITE_MODES.search(mode))
148
+
149
+ def _patched_open(self, file, mode="r", *args, **kwargs):
150
+ """Replacement for builtins.open that intercepts protected writes."""
151
+ filepath = Path(str(file))
152
+
153
+ if self._is_write_mode(mode) and self._is_protected(filepath):
154
+ self._blocked_count += 1
155
+ logger.debug("🫥 Blocked write to %s (mode=%s)", filepath, mode)
156
+ # Return a no-op StringIO/BytesIO depending on mode.
157
+ if "b" in mode:
158
+ return io.BytesIO()
159
+ return io.StringIO()
160
+
161
+ return self._original_open(file, mode, *args, **kwargs)
forgetted/session.py ADDED
@@ -0,0 +1,171 @@
1
+ """
2
+ forgetted.session — ForgetSession orchestrator.
3
+
4
+ Coordinates multiple persistence adapters during a forgetted window.
5
+ This is the main entry point for v0.2 usage.
6
+
7
+ Ordering contract for stop():
8
+ 1. Re-enable all adapters (restore normal writes) — always happens first.
9
+ 2. Run cleanup on each adapter (post-window sweep) — runs after enable.
10
+ 3. Clean session log if requested.
11
+
12
+ This ordering ensures that cleanup code can write freely (e.g., mem0.delete)
13
+ because adapters are already re-enabled when cleanup runs.
14
+
15
+ If any adapter raises during disable/enable/cleanup, the error is logged
16
+ but other adapters continue. No single adapter failure blocks the rest.
17
+
18
+ Usage::
19
+
20
+ from forgetted.session import ForgetSession
21
+
22
+ with ForgetSession("/path/to/workspace") as fs:
23
+ # ... everything here is forgetted ...
24
+ pass
25
+ # adapters re-enabled, cleanup done, session log deleted
26
+ """
27
+
28
+ import logging
29
+ from typing import Optional
30
+
31
+ from .adapters.base import PersistenceAdapter
32
+ from .adapters.file_write import FileWriteAdapter
33
+ from .checkpoint import create_checkpoint
34
+ from .cleaner import delete_session_log, find_session_log
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class ForgetSession:
40
+ """Orchestrate a forgetted window across multiple persistence adapters.
41
+
42
+ Parameters
43
+ ----------
44
+ workspace : str
45
+ Absolute path to the agent workspace root.
46
+ adapters : list[PersistenceAdapter], optional
47
+ Additional adapters to register. ``FileWriteAdapter`` is always
48
+ included as the safety-net layer.
49
+ session_id : str, optional
50
+ Session ID for log cleanup after the window.
51
+ agents_dir : str, optional
52
+ Directory to search for session logs (for cleanup).
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ workspace: str,
58
+ adapters: Optional[list[PersistenceAdapter]] = None,
59
+ session_id: Optional[str] = None,
60
+ agents_dir: Optional[str] = None,
61
+ ):
62
+ self.workspace = workspace
63
+ self.session_id = session_id
64
+ self.agents_dir = agents_dir
65
+ self._started = False
66
+
67
+ # FileWriteAdapter is always the base layer (safety net).
68
+ self._adapters: list[PersistenceAdapter] = [FileWriteAdapter(workspace)]
69
+ if adapters:
70
+ self._adapters.extend(adapters)
71
+
72
+ @property
73
+ def is_active(self) -> bool:
74
+ """True if the forgetted window is currently open."""
75
+ return self._started
76
+
77
+ @property
78
+ def adapters(self) -> list[PersistenceAdapter]:
79
+ """List of registered adapters (read-only view)."""
80
+ return list(self._adapters)
81
+
82
+ def add_adapter(self, adapter: PersistenceAdapter) -> None:
83
+ """Register an additional persistence adapter.
84
+
85
+ Must be called before ``start()``. Raises RuntimeError if
86
+ the session is already active.
87
+ """
88
+ if self._started:
89
+ raise RuntimeError("Cannot add adapters while forgetted session is active")
90
+ self._adapters.append(adapter)
91
+ logger.info("🫥 Registered adapter: %s", adapter.name)
92
+
93
+ def start(self, checkpoint_summary: Optional[str] = None) -> None:
94
+ """Open the forgetted window.
95
+
96
+ Parameters
97
+ ----------
98
+ checkpoint_summary : str, optional
99
+ If provided, saves a resumption checkpoint before disabling.
100
+ """
101
+ if self._started:
102
+ return # Idempotent — double-start is a no-op.
103
+
104
+ # Save checkpoint before going dark.
105
+ if checkpoint_summary:
106
+ create_checkpoint(checkpoint_summary, self.workspace)
107
+
108
+ # Disable all adapters. Each adapter is independent — one failure
109
+ # doesn't prevent others from disabling.
110
+ for adapter in self._adapters:
111
+ try:
112
+ adapter.disable()
113
+ except Exception as exc:
114
+ logger.error("🫥 Failed to disable adapter '%s': %s", adapter.name, exc)
115
+
116
+ self._started = True
117
+ adapter_names = ", ".join(a.name for a in self._adapters)
118
+ logger.info("🫥 Forgetted session started — %d adapters active (%s)", len(self._adapters), adapter_names)
119
+
120
+ def stop(self, clean: bool = True) -> None:
121
+ """Close the forgetted window.
122
+
123
+ Ordering contract:
124
+ 1. Re-enable all adapters (restore normal writes)
125
+ 2. Run cleanup on each adapter (if clean=True)
126
+ 3. Delete session log (if session_id and agents_dir provided)
127
+
128
+ Parameters
129
+ ----------
130
+ clean : bool
131
+ If True (default), run cleanup sweep on all adapters and
132
+ delete the session log. Set to False to skip cleanup.
133
+ """
134
+ if not self._started:
135
+ return # Idempotent — stop before start is a no-op.
136
+
137
+ # Step 1: Re-enable all adapters first.
138
+ # This ensures cleanup code can write freely (e.g., mem0.delete).
139
+ for adapter in self._adapters:
140
+ try:
141
+ adapter.enable()
142
+ except Exception as exc:
143
+ logger.error("🫥 Failed to enable adapter '%s': %s", adapter.name, exc)
144
+
145
+ # Step 2: Run cleanup sweep.
146
+ if clean:
147
+ for adapter in self._adapters:
148
+ try:
149
+ adapter.cleanup()
150
+ except Exception as exc:
151
+ logger.error("🫥 Cleanup failed for adapter '%s': %s", adapter.name, exc)
152
+
153
+ # Step 3: Delete session log.
154
+ if self.session_id and self.agents_dir:
155
+ log = find_session_log(self.session_id, self.agents_dir)
156
+ if log:
157
+ delete_session_log(log)
158
+
159
+ self._started = False
160
+ logger.info("🫥 Forgetted session stopped (clean=%s)", clean)
161
+
162
+ # -- context manager ----------------------------------------------------
163
+
164
+ def __enter__(self):
165
+ self.start()
166
+ return self
167
+
168
+ def __exit__(self, exc_type, exc_val, exc_tb):
169
+ # Always stop and clean up, even if an exception occurred.
170
+ self.stop(clean=True)
171
+ return False # Don't suppress exceptions.
forgetted/trigger.py ADDED
@@ -0,0 +1,35 @@
1
+ """
2
+ forgetted.trigger — Detect forgetted mode activation in user messages.
3
+
4
+ Supports exact commands (/forgetted) and natural-language variants
5
+ ("forget this", "go off the record"). Case-insensitive matching.
6
+ """
7
+
8
+ # Trigger phrases — checked via substring match against lowercased input.
9
+ TRIGGERS: list[str] = [
10
+ "/forgetted",
11
+ "/forget",
12
+ "forget this",
13
+ "go off the record",
14
+ "off the record",
15
+ "forgetted mode",
16
+ "this is off the record",
17
+ "don't remember this",
18
+ ]
19
+
20
+
21
+ def is_forget_trigger(message: str) -> bool:
22
+ """Check whether a user message contains a forgetted trigger.
23
+
24
+ Parameters
25
+ ----------
26
+ message : str
27
+ Raw user message text.
28
+
29
+ Returns
30
+ -------
31
+ bool
32
+ True if the message activates forgetted mode.
33
+ """
34
+ lowered = message.lower()
35
+ return any(trigger in lowered for trigger in TRIGGERS)
@@ -0,0 +1,219 @@
1
+ Metadata-Version: 2.4
2
+ Name: forgetted
3
+ Version: 0.2.0
4
+ Summary: Selective memory governance for AI agents — branch the timeline, never merge back
5
+ Author-email: Hermes Labs <lpcisystems@gmail.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/roli-lpci/forgetted
8
+ Project-URL: Repository, https://github.com/roli-lpci/forgetted
9
+ Project-URL: Issues, https://github.com/roli-lpci/forgetted/issues
10
+ Keywords: llm,agent,memory,privacy,ai-safety,fork,selective-forget
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Provides-Extra: trash
24
+ Requires-Dist: send2trash>=1.8.0; extra == "trash"
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7.0; extra == "dev"
27
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
28
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
29
+
30
+ <p align="center">
31
+ <h1 align="center">🫥 forgetted</h1>
32
+ <p align="center"><strong>Your AI agent remembers everything. Now it doesn't have to.</strong></p>
33
+ <p align="center">
34
+ <a href="https://pypi.org/project/forgetted/"><img src="https://img.shields.io/pypi/v/forgetted?color=blue" alt="PyPI"></a>
35
+ <a href="https://github.com/roli-lpci/forgetted/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-green" alt="License"></a>
36
+ <a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.9+-blue" alt="Python"></a>
37
+ </p>
38
+ </p>
39
+
40
+ ---
41
+
42
+ **forgetted** gives AI agents selective memory governance. One line of code, and your agent keeps full context but writes nothing to memory.
43
+
44
+ > Traditional incognito is dumb: no past, no future, fully isolated.
45
+ > **forgetted** gives you: full continuity + selective non-persistence.
46
+
47
+ ```python
48
+ from forgetted import ForgetSession
49
+
50
+ with ForgetSession("/path/to/workspace"):
51
+ agent.chat("this conversation never happened")
52
+ # ↑ No trace in memory, logs, or vector DB. Agent resumes normally.
53
+ ```
54
+
55
+ ## Why?
56
+
57
+ AI agents write everything: memory files, session logs, vector embeddings, deliverables. Sometimes you need context without consequences:
58
+
59
+ - 💬 **Sensitive conversations** that shouldn't persist in agent memory
60
+ - 🧪 **Experiments** you don't want polluting your agent's knowledge base
61
+ - 🔒 **Client data** discussed but not stored
62
+ - 🤔 **Brainstorming** that shouldn't bias future responses
63
+
64
+ **forgetted** is not a prompt. It's software that wraps the agent's persistence layer — writes silently vanish, reads still work, and the agent resumes normally after.
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ pip install forgetted
70
+ ```
71
+
72
+ ## Quick Start
73
+
74
+ ### Simple (file-level protection)
75
+
76
+ ```python
77
+ from forgetted import ForgetSession
78
+
79
+ # Everything inside is forgetted — writes to memory/, logs, deliverables vanish
80
+ with ForgetSession("/path/to/agent/workspace"):
81
+ agent.chat("tell me about the secret project")
82
+ ```
83
+
84
+ ### With vector DB protection
85
+
86
+ ```python
87
+ from forgetted import ForgetSession
88
+ from forgetted.adapters.mem0 import Mem0Adapter
89
+
90
+ session = ForgetSession(
91
+ workspace="/path/to/workspace",
92
+ adapters=[Mem0Adapter(memory_instance, user_id="roli")],
93
+ )
94
+ session.start(checkpoint_summary="Discussing API design")
95
+ # ... conversation happens with full context, zero persistence ...
96
+ session.stop() # re-enables all layers, cleans up
97
+ ```
98
+
99
+ ### Trigger detection (for chat agents)
100
+
101
+ ```python
102
+ from forgetted import is_forget_trigger, ForgetSession
103
+
104
+ if is_forget_trigger(user_message): # "/forget", "off the record", etc.
105
+ with ForgetSession(workspace):
106
+ handle_conversation()
107
+ ```
108
+
109
+ ## What Gets Blocked
110
+
111
+ | Layer | How | Status |
112
+ |---|---|---|
113
+ | Memory files (`memory/*.md`) | `builtins.open` patch | ✅ Blocked |
114
+ | Deliverables / audit logs | `builtins.open` patch | ✅ Blocked |
115
+ | Session logs (`*.jsonl`) | Blocked + deleted on exit | ✅ Blocked |
116
+ | mem0 / semantic memory | Method patch on `add`/`update` | ✅ Blocked |
117
+ | Any custom persistence | Write your own adapter | 🔌 Extensible |
118
+
119
+ ## How It Works
120
+
121
+ **forgetted** uses a layered defense:
122
+
123
+ 1. **`FileWriteAdapter`** (always on) — patches `builtins.open` to intercept writes to protected paths. Returns no-op file handles instead of raising — agent code doesn't crash, writes just vanish.
124
+
125
+ 2. **`Mem0Adapter`** (opt-in) — patches `memory.add()` and `memory.update()` during the window. Post-window cleanup deletes any memories that leaked through.
126
+
127
+ 3. **`ForgetSession`** orchestrates everything: checkpoint → disable adapters → run conversation → enable adapters → cleanup → delete session log.
128
+
129
+ Reads are **never** blocked. The agent has full context — it just can't write new context.
130
+
131
+ ## Write Your Own Adapter
132
+
133
+ Any persistence layer can be controlled:
134
+
135
+ ```python
136
+ from forgetted.adapters.base import PersistenceAdapter
137
+
138
+ class RedisAdapter(PersistenceAdapter):
139
+ name = "redis"
140
+
141
+ def disable(self):
142
+ self._client.config_set("save", "")
143
+ self._active = True
144
+
145
+ def enable(self):
146
+ self._client.config_set("save", "3600 1")
147
+ self._active = False
148
+
149
+ def cleanup(self):
150
+ for key in self._window_keys:
151
+ self._client.delete(key)
152
+
153
+ @property
154
+ def is_active(self): return self._active
155
+ ```
156
+
157
+ Register it: `ForgetSession(workspace, adapters=[RedisAdapter(client)])`
158
+
159
+ ## Trigger Phrases
160
+
161
+ Built-in detection for natural-language triggers:
162
+
163
+ | Trigger | Example |
164
+ |---|---|
165
+ | `/forgetted` | "/forgetted" |
166
+ | `/forget` | "/forget" |
167
+ | `forget this` | "hey, forget this conversation" |
168
+ | `off the record` | "let's go off the record" |
169
+ | `forgetted mode` | "enable forgetted mode" |
170
+ | `don't remember this` | "don't remember this" |
171
+
172
+ ## Architecture
173
+
174
+ ```
175
+ ┌─────────────────────────────────────────┐
176
+ │ ForgetSession │
177
+ │ (orchestrator — context manager) │
178
+ ├─────────────────────────────────────────┤
179
+ │ ┌──────────────┐ ┌──────────────┐ │
180
+ │ │ FileWrite │ │ Mem0 │ │
181
+ │ │ Adapter │ │ Adapter │ ... │
182
+ │ │ (safety net) │ │ (opt-in) │ │
183
+ │ └──────────────┘ └──────────────┘ │
184
+ ├─────────────────────────────────────────┤
185
+ │ checkpoint → disable → conversation │
186
+ │ → enable → cleanup → delete log │
187
+ └─────────────────────────────────────────┘
188
+ ```
189
+
190
+ ## What This Really Is
191
+
192
+ This is not a UX toggle. It's a **memory governance primitive**.
193
+
194
+ Like git: you branch, but you never merge back. The conversation exists in context but is never written to the agent's persistent state. After the window closes, it's as if it never happened.
195
+
196
+ > *"I want context… but I don't want consequences."*
197
+
198
+ ## Tested
199
+
200
+ 97 tests including an adversarial suite:
201
+ - ✅ Write blocking (open w/a/x/wb/r+, symlinks, binary)
202
+ - ✅ Trigger detection (zero false positives on "forgot password", "forgetful", etc.)
203
+ - ✅ Adapter error isolation (one failing adapter doesn't break others)
204
+ - ✅ Exception safety (cleanup runs even if conversation crashes)
205
+ - ✅ Idempotency (double-start, stop-before-start, double-stop all safe)
206
+
207
+ Known limitations are [documented as xfail tests](tests/test_adversarial.py) — not hidden.
208
+
209
+ ## Threat Model
210
+
211
+ **What forgetted blocks:** Everything the agent controls — memory files, vector DB writes, session logs, deliverables.
212
+
213
+ **What forgetted does NOT block:** LLM API provider logs, network telemetry, OS-level forensics. That's not the point.
214
+
215
+ **The guarantee:** *"If someone looks through the agent's memory and logs, they won't find what you forgetted."*
216
+
217
+ ## License
218
+
219
+ [Apache-2.0](LICENSE) — Hermes Labs
@@ -0,0 +1,14 @@
1
+ forgetted/__init__.py,sha256=aE6FpVL6hGharQBZt75M_wdrhmR-r11YSaZYglyI4Hc,1218
2
+ forgetted/checkpoint.py,sha256=xR8t6RW3vAAzwtFelF9LKevrq8gfJOlLjr6rqqRXGFk,2518
3
+ forgetted/cleaner.py,sha256=D_WGDhoqQkkHFj6KI9IFfTXDlVeHh3t469TWt5i3oSo,2674
4
+ forgetted/guard.py,sha256=VrSby8npY8lDeH62k81vy2F8IEeo6R_-4uVpR_ExVgE,5496
5
+ forgetted/session.py,sha256=Kz0HWo-_hsP3pnnGVsIiAPdifxOv0k-0F6D0bZ3G2HE,6062
6
+ forgetted/trigger.py,sha256=cyH7uGeQ65qQ5rFhVEUxa-rqlTM977fLe89mmVw6yFM,873
7
+ forgetted/adapters/__init__.py,sha256=jXDntEYsNpa1HV1zP8g-VLl4Gn5Jk2Z7tOtnmvR7zlU,611
8
+ forgetted/adapters/base.py,sha256=X6qgH0OPhBAOvoWbPJc8fEWQJsqy5r3DxzWRwKnSxz4,2173
9
+ forgetted/adapters/file_write.py,sha256=YmVPWBqX-JybEz-Qd7659fgjVtAJ3o8tnhggoU_LZWk,1444
10
+ forgetted/adapters/mem0.py,sha256=nOuVmGT1PbJ3xhpJ8zys_kFbky4KvCqkEEbaPoIqoqc,4291
11
+ forgetted-0.2.0.dist-info/METADATA,sha256=lhOJLYCepyWSJNUIt7LisK4eSy43-W_IvHuFY4qA7EI,8458
12
+ forgetted-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
13
+ forgetted-0.2.0.dist-info/top_level.txt,sha256=CgRdJH6_JRuDzweoHymDCBmeuxxdc_OSJUnsMOcsJiA,10
14
+ forgetted-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ forgetted