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 +41 -0
- forgetted/adapters/__init__.py +25 -0
- forgetted/adapters/base.py +72 -0
- forgetted/adapters/file_write.py +53 -0
- forgetted/adapters/mem0.py +127 -0
- forgetted/checkpoint.py +82 -0
- forgetted/cleaner.py +88 -0
- forgetted/guard.py +161 -0
- forgetted/session.py +171 -0
- forgetted/trigger.py +35 -0
- forgetted-0.2.0.dist-info/METADATA +219 -0
- forgetted-0.2.0.dist-info/RECORD +14 -0
- forgetted-0.2.0.dist-info/WHEEL +5 -0
- forgetted-0.2.0.dist-info/top_level.txt +1 -0
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
|
forgetted/checkpoint.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
forgetted
|