plug-play-memory 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.
- plug_play_memory/__init__.py +23 -0
- plug_play_memory/audit.py +209 -0
- plug_play_memory/auth.py +155 -0
- plug_play_memory/cli.py +413 -0
- plug_play_memory/consolidation.py +172 -0
- plug_play_memory/emitter.py +337 -0
- plug_play_memory/events.py +36 -0
- plug_play_memory/judge.py +461 -0
- plug_play_memory/policies/builtin/_base.yaml +18 -0
- plug_play_memory/policies/builtin/coding.yaml +53 -0
- plug_play_memory/policies/builtin/financial.yaml +50 -0
- plug_play_memory/policies/builtin/study.yaml +50 -0
- plug_play_memory/policy.py +353 -0
- plug_play_memory/server.py +374 -0
- plug_play_memory/storage.py +495 -0
- plug_play_memory-0.2.0.dist-info/METADATA +487 -0
- plug_play_memory-0.2.0.dist-info/RECORD +21 -0
- plug_play_memory-0.2.0.dist-info/WHEEL +5 -0
- plug_play_memory-0.2.0.dist-info/entry_points.txt +2 -0
- plug_play_memory-0.2.0.dist-info/licenses/LICENSE +201 -0
- plug_play_memory-0.2.0.dist-info/top_level.txt +1 -0
plug_play_memory/cli.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI for plug-play-memory.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
agentmemory init <name> [--type coding|study|financial|default]
|
|
6
|
+
Scaffold a new project directory with config + policies.
|
|
7
|
+
|
|
8
|
+
agentmemory serve [--db memory.db] [--policies policies/custom] [--config config.yaml]
|
|
9
|
+
Run the MCP server (stdio transport) for Claude Desktop / Cursor.
|
|
10
|
+
|
|
11
|
+
agentmemory policy list
|
|
12
|
+
Show all loaded policy layers.
|
|
13
|
+
|
|
14
|
+
agentmemory policy show --agent-id <id> --agent-type <type>
|
|
15
|
+
Show effective policy for an agent (resolved across all layers).
|
|
16
|
+
|
|
17
|
+
agentmemory policy customize <name>
|
|
18
|
+
Scaffold an empty custom policy file in policies/custom/.
|
|
19
|
+
|
|
20
|
+
agentmemory policy validate
|
|
21
|
+
Validate all policy YAML files for syntax errors.
|
|
22
|
+
|
|
23
|
+
agentmemory doctor
|
|
24
|
+
Check that everything is set up correctly.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import os
|
|
31
|
+
import shutil
|
|
32
|
+
import sys
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
from .policy import PolicyEngine
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
TEMPLATE_CONFIG = """\
|
|
41
|
+
# plug-play-memory configuration
|
|
42
|
+
db_path: memory.db
|
|
43
|
+
|
|
44
|
+
auth:
|
|
45
|
+
enabled: true
|
|
46
|
+
default_agent_id: local
|
|
47
|
+
default_agent_type: coding
|
|
48
|
+
default_scopes:
|
|
49
|
+
- memory:read
|
|
50
|
+
- memory:write
|
|
51
|
+
- memory:delete
|
|
52
|
+
- permission:check
|
|
53
|
+
- admin:consolidate
|
|
54
|
+
- admin:audit
|
|
55
|
+
# tokens:
|
|
56
|
+
# - id: token-coder-1
|
|
57
|
+
# token: sk-coder-1-abc123...
|
|
58
|
+
# agent_id: coder-1
|
|
59
|
+
# agent_type: coding
|
|
60
|
+
# scopes: [memory:read, memory:write, permission:check]
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# init
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def cmd_init(args: argparse.Namespace) -> None:
|
|
69
|
+
project_dir = Path(args.name)
|
|
70
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
(project_dir / "policies").mkdir(exist_ok=True)
|
|
72
|
+
(project_dir / "policies" / "custom").mkdir(exist_ok=True)
|
|
73
|
+
|
|
74
|
+
config_path = project_dir / "config.yaml"
|
|
75
|
+
if not config_path.exists():
|
|
76
|
+
# Customize template with chosen agent_type
|
|
77
|
+
cfg = TEMPLATE_CONFIG
|
|
78
|
+
if args.type and args.type != "coding":
|
|
79
|
+
cfg = cfg.replace("default_agent_type: coding",
|
|
80
|
+
f"default_agent_type: {args.type}")
|
|
81
|
+
if args.agent_id:
|
|
82
|
+
cfg = cfg.replace("default_agent_id: local",
|
|
83
|
+
f"default_agent_id: {args.agent_id}")
|
|
84
|
+
config_path.write_text(cfg)
|
|
85
|
+
|
|
86
|
+
# .gitignore for the SQLite db
|
|
87
|
+
gitignore = project_dir / ".gitignore"
|
|
88
|
+
if not gitignore.exists():
|
|
89
|
+
gitignore.write_text("*.db\n*.db-wal\n*.db-shm\n__pycache__/\n*.pyc\n")
|
|
90
|
+
|
|
91
|
+
print(f"Initialized plug-play-memory project in ./{project_dir}/")
|
|
92
|
+
print(f" {project_dir}/config.yaml")
|
|
93
|
+
print(f" {project_dir}/policies/custom/ (your overrides go here)")
|
|
94
|
+
print(f" {project_dir}/.gitignore")
|
|
95
|
+
print()
|
|
96
|
+
print(f"Next steps:")
|
|
97
|
+
print(f" cd {project_dir}")
|
|
98
|
+
print(f" agentmemory serve")
|
|
99
|
+
print()
|
|
100
|
+
print(f"To use with Claude Desktop, add to claude_desktop_config.json:")
|
|
101
|
+
print(f''' {{
|
|
102
|
+
"mcpServers": {{
|
|
103
|
+
"plug-play-memory": {{
|
|
104
|
+
"command": "agentmemory",
|
|
105
|
+
"args": ["serve", "--db", "memory.db", "--config", "config.yaml"]
|
|
106
|
+
}}
|
|
107
|
+
}}
|
|
108
|
+
}}''')
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# serve
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def cmd_serve(args: argparse.Namespace) -> None:
|
|
116
|
+
from .server import build_server
|
|
117
|
+
if args.agent_id:
|
|
118
|
+
os.environ["AGENTMEMORY_AGENT_ID"] = args.agent_id
|
|
119
|
+
if args.agent_type:
|
|
120
|
+
os.environ["AGENTMEMORY_AGENT_TYPE"] = args.agent_type
|
|
121
|
+
server = build_server(
|
|
122
|
+
db_path=args.db,
|
|
123
|
+
custom_policy_dir=args.policies,
|
|
124
|
+
auth_config_path=args.config,
|
|
125
|
+
)
|
|
126
|
+
server.run(transport="stdio")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# policy list / show / customize / validate
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def cmd_policy_list(args: argparse.Namespace) -> None:
|
|
134
|
+
policy = PolicyEngine(custom_dir=args.policies)
|
|
135
|
+
print("Loaded policy layers:")
|
|
136
|
+
for layer in policy.list_layers():
|
|
137
|
+
print(f" {layer}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_policy_show(args: argparse.Namespace) -> None:
|
|
141
|
+
policy = PolicyEngine(custom_dir=args.policies)
|
|
142
|
+
policy.set_agent_type(args.agent_id, args.agent_type)
|
|
143
|
+
print(f"Effective policy for agent_id={args.agent_id}, "
|
|
144
|
+
f"agent_type={args.agent_type}")
|
|
145
|
+
print()
|
|
146
|
+
print(f"Layers evaluated (in order):")
|
|
147
|
+
for src in policy.list_layers():
|
|
148
|
+
print(f" {src}")
|
|
149
|
+
print()
|
|
150
|
+
print("Action resolutions (sample):")
|
|
151
|
+
sample_actions = [
|
|
152
|
+
("read_memory", {}),
|
|
153
|
+
("write_memory", {"category": "decision"}), # need category for policy match
|
|
154
|
+
("write_memory", {"category": "transient"}), # transient usually denied
|
|
155
|
+
("delete_memory", {}),
|
|
156
|
+
("recall_memory", {}),
|
|
157
|
+
("send_email", {}),
|
|
158
|
+
("make_purchase", {}),
|
|
159
|
+
("execute_code", {}),
|
|
160
|
+
("access_credentials", {}),
|
|
161
|
+
("delete_database", {}),
|
|
162
|
+
("make_payment", {"approved_by_user": True, "amount": 1000}),
|
|
163
|
+
]
|
|
164
|
+
print(f" {'ACTION':<28} {'CONTEXT':<32} {'ALLOWED':<8} REASON")
|
|
165
|
+
print(f" {'-'*28} {'-'*32} {'-'*8} {'-'*40}")
|
|
166
|
+
for action, ctx in sample_actions:
|
|
167
|
+
d = policy.check(args.agent_id, action, ctx)
|
|
168
|
+
allowed_str = "yes" if d.allowed else "NO"
|
|
169
|
+
ctx_str = str(ctx)[:30] if ctx else "-"
|
|
170
|
+
print(f" {action:<28} {ctx_str:<32} {allowed_str:<8} {d.reason[:60]}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_policy_customize(args: argparse.Namespace) -> None:
|
|
174
|
+
custom_dir = Path(args.policies) / "custom"
|
|
175
|
+
custom_dir.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
|
|
177
|
+
# Validate name — no path separators, no reserved names
|
|
178
|
+
if not args.name or not args.name.replace("_", "").replace("-", "").isalnum():
|
|
179
|
+
print(f"ERROR: name must be alphanumeric (with _ or - only)")
|
|
180
|
+
sys.exit(1)
|
|
181
|
+
if args.name in ("_base", "coding", "study", "financial", "default"):
|
|
182
|
+
print(f"ERROR: cannot override built-in pack name '{args.name}'")
|
|
183
|
+
sys.exit(1)
|
|
184
|
+
|
|
185
|
+
target = custom_dir / f"{args.name}.yaml"
|
|
186
|
+
|
|
187
|
+
# Path-traversal safety: resolved target must be inside custom_dir
|
|
188
|
+
try:
|
|
189
|
+
target_resolved = target.resolve()
|
|
190
|
+
custom_resolved = custom_dir.resolve()
|
|
191
|
+
if not str(target_resolved).startswith(str(custom_resolved) + os.sep):
|
|
192
|
+
print(f"ERROR: name resolves outside custom/ directory")
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
except Exception as e:
|
|
195
|
+
print(f"ERROR: invalid name: {e}")
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
|
|
198
|
+
if target.exists():
|
|
199
|
+
print(f"Already exists: {target}")
|
|
200
|
+
return
|
|
201
|
+
target.write_text(
|
|
202
|
+
f"""# Custom override for '{args.name}'.
|
|
203
|
+
# Extends (does NOT replace) the built-in '{args.name}' pack.
|
|
204
|
+
# Rules here ADD to or OVERRIDE matching built-in rules.
|
|
205
|
+
# Deny rules in builtin/_base.yaml are absolute and cannot be weakened.
|
|
206
|
+
|
|
207
|
+
name: {args.name}
|
|
208
|
+
description: Custom override for {args.name}.
|
|
209
|
+
default: deny
|
|
210
|
+
|
|
211
|
+
rules:
|
|
212
|
+
# Example: allow a new action
|
|
213
|
+
# - action: my_custom_action
|
|
214
|
+
# allow: true
|
|
215
|
+
|
|
216
|
+
# Example: tighten an existing rule (deny wins)
|
|
217
|
+
# - action: sync_to_flashcard_app
|
|
218
|
+
# allow: false
|
|
219
|
+
"""
|
|
220
|
+
)
|
|
221
|
+
print(f"Created: {target}")
|
|
222
|
+
print(f"Edit it, then restart the server.")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def cmd_policy_validate(args: argparse.Namespace) -> None:
|
|
226
|
+
custom_dir = Path(args.policies) / "custom"
|
|
227
|
+
builtin_dir = Path(__file__).parent / "policies" / "builtin"
|
|
228
|
+
all_paths = sorted(builtin_dir.glob("*.yaml"))
|
|
229
|
+
if custom_dir.exists():
|
|
230
|
+
all_paths += sorted(custom_dir.glob("*.yaml"))
|
|
231
|
+
errors = 0
|
|
232
|
+
for path in all_paths:
|
|
233
|
+
try:
|
|
234
|
+
spec = yaml.safe_load(path.read_text())
|
|
235
|
+
if spec is None:
|
|
236
|
+
spec = {}
|
|
237
|
+
if not isinstance(spec, dict):
|
|
238
|
+
raise ValueError("top-level must be a mapping")
|
|
239
|
+
rules = spec.get("rules", [])
|
|
240
|
+
if rules is None:
|
|
241
|
+
rules = []
|
|
242
|
+
if not isinstance(rules, list):
|
|
243
|
+
raise ValueError("'rules' must be a list")
|
|
244
|
+
for r in rules:
|
|
245
|
+
if "action" not in r:
|
|
246
|
+
raise ValueError(f"rule missing 'action': {r}")
|
|
247
|
+
print(f" OK {path}")
|
|
248
|
+
except Exception as e:
|
|
249
|
+
print(f" FAIL {path}: {e}")
|
|
250
|
+
errors += 1
|
|
251
|
+
if errors:
|
|
252
|
+
print(f"\n{errors} validation error(s).")
|
|
253
|
+
sys.exit(1)
|
|
254
|
+
print(f"\nAll policy files valid.")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# doctor
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def cmd_doctor(args: argparse.Namespace) -> None:
|
|
262
|
+
print("plug-play-memory doctor")
|
|
263
|
+
print("=" * 50)
|
|
264
|
+
ok = True
|
|
265
|
+
|
|
266
|
+
# Python version
|
|
267
|
+
py_version = sys.version.split()[0]
|
|
268
|
+
if sys.version_info >= (3, 10):
|
|
269
|
+
print(f" Python: {py_version:<24} OK")
|
|
270
|
+
else:
|
|
271
|
+
print(f" Python: {py_version:<24} FAIL (need 3.10+)")
|
|
272
|
+
ok = False
|
|
273
|
+
|
|
274
|
+
# PyYAML
|
|
275
|
+
try:
|
|
276
|
+
import yaml
|
|
277
|
+
print(f" PyYAML: {yaml.__version__:<24} OK")
|
|
278
|
+
except ImportError:
|
|
279
|
+
print(f" PyYAML: not installed{'':<14} FAIL")
|
|
280
|
+
ok = False
|
|
281
|
+
|
|
282
|
+
# MCP
|
|
283
|
+
try:
|
|
284
|
+
import mcp
|
|
285
|
+
version = getattr(mcp, "__version__", "installed")
|
|
286
|
+
print(f" MCP: {version:<24} OK")
|
|
287
|
+
except ImportError:
|
|
288
|
+
print(f" MCP: not installed{'':<16} FAIL (run: pip install 'mcp>=1.20.0')")
|
|
289
|
+
ok = False
|
|
290
|
+
|
|
291
|
+
# SQLite version + features (L5 fix)
|
|
292
|
+
try:
|
|
293
|
+
import sqlite3
|
|
294
|
+
sqlite_version = sqlite3.sqlite_version
|
|
295
|
+
print(f" SQLite: {sqlite_version:<23} OK")
|
|
296
|
+
# Test WAL mode support
|
|
297
|
+
test_db = ":memory:"
|
|
298
|
+
conn = sqlite3.connect(test_db)
|
|
299
|
+
try:
|
|
300
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
301
|
+
except sqlite3.OperationalError:
|
|
302
|
+
print(f" SQLite WAL mode: not supported{'':<10} FAIL")
|
|
303
|
+
ok = False
|
|
304
|
+
# Test JSON1
|
|
305
|
+
try:
|
|
306
|
+
conn.execute("SELECT json_extract('{}', '$.x')")
|
|
307
|
+
except sqlite3.OperationalError:
|
|
308
|
+
print(f" SQLite JSON1: not supported{'':<11} FAIL")
|
|
309
|
+
ok = False
|
|
310
|
+
# Test BLAKE2b (always available in Python's hashlib, but check)
|
|
311
|
+
import hashlib
|
|
312
|
+
try:
|
|
313
|
+
hashlib.blake2b(b"test")
|
|
314
|
+
except Exception:
|
|
315
|
+
print(f" BLAKE2b: not available{'':<16} FAIL")
|
|
316
|
+
ok = False
|
|
317
|
+
conn.close()
|
|
318
|
+
except Exception as e:
|
|
319
|
+
print(f" SQLite: error ({e})")
|
|
320
|
+
ok = False
|
|
321
|
+
|
|
322
|
+
# Policy packs
|
|
323
|
+
builtin_dir = Path(__file__).parent / "policies" / "builtin"
|
|
324
|
+
expected = ["_base.yaml", "coding.yaml", "study.yaml", "financial.yaml"]
|
|
325
|
+
for name in expected:
|
|
326
|
+
p = builtin_dir / name
|
|
327
|
+
if p.exists():
|
|
328
|
+
print(f" Policy pack {name:<20} OK")
|
|
329
|
+
else:
|
|
330
|
+
print(f" Policy pack {name:<20} FAIL (missing)")
|
|
331
|
+
ok = False
|
|
332
|
+
|
|
333
|
+
# Custom policy dir
|
|
334
|
+
custom_dir = Path(args.policies) / "custom"
|
|
335
|
+
if custom_dir.exists():
|
|
336
|
+
custom_files = list(custom_dir.glob("*.yaml"))
|
|
337
|
+
print(f" Custom policies: {len(custom_files)} file(s){'':<10} OK")
|
|
338
|
+
else:
|
|
339
|
+
print(f" Custom policies: dir missing{'':<13} (will be created on first use)")
|
|
340
|
+
|
|
341
|
+
print()
|
|
342
|
+
if ok:
|
|
343
|
+
print(" All checks passed.")
|
|
344
|
+
else:
|
|
345
|
+
print(" Some checks failed. Fix and re-run.")
|
|
346
|
+
sys.exit(1)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
# Main
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
def main() -> None:
|
|
354
|
+
parser = argparse.ArgumentParser(prog="agentmemory")
|
|
355
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
356
|
+
|
|
357
|
+
# init
|
|
358
|
+
p_init = sub.add_parser("init", help="Scaffold a new project.")
|
|
359
|
+
p_init.add_argument("name", help="Project directory name.")
|
|
360
|
+
p_init.add_argument("--type", default="coding",
|
|
361
|
+
choices=["coding", "study", "financial", "default"],
|
|
362
|
+
help="Agent type for default policy.")
|
|
363
|
+
p_init.add_argument("--agent-id", default=None,
|
|
364
|
+
help="Default agent_id to use.")
|
|
365
|
+
p_init.set_defaults(func=cmd_init)
|
|
366
|
+
|
|
367
|
+
# serve
|
|
368
|
+
p_serve = sub.add_parser("serve", help="Run the MCP server (stdio).")
|
|
369
|
+
p_serve.add_argument("--db", default="memory.db")
|
|
370
|
+
p_serve.add_argument("--policies", default="policies/custom",
|
|
371
|
+
help="Path to custom policy directory.")
|
|
372
|
+
p_serve.add_argument("--config", default="config.yaml",
|
|
373
|
+
help="Path to auth config YAML.")
|
|
374
|
+
p_serve.add_argument("--agent-id", default=None)
|
|
375
|
+
p_serve.add_argument("--agent-type", default=None)
|
|
376
|
+
p_serve.set_defaults(func=cmd_serve)
|
|
377
|
+
|
|
378
|
+
# policy
|
|
379
|
+
p_policy = sub.add_parser("policy", help="Policy management.")
|
|
380
|
+
p_sub = p_policy.add_subparsers(dest="policy_command", required=True)
|
|
381
|
+
|
|
382
|
+
p_list = p_sub.add_parser("list", help="List loaded policy layers.")
|
|
383
|
+
p_list.add_argument("--policies", default="policies/custom")
|
|
384
|
+
p_list.set_defaults(func=cmd_policy_list)
|
|
385
|
+
|
|
386
|
+
p_show = p_sub.add_parser("show", help="Show effective policy for an agent.")
|
|
387
|
+
p_show.add_argument("--agent-id", required=True)
|
|
388
|
+
p_show.add_argument("--agent-type", required=True,
|
|
389
|
+
choices=["coding", "study", "financial", "default"])
|
|
390
|
+
p_show.add_argument("--policies", default="policies/custom")
|
|
391
|
+
p_show.set_defaults(func=cmd_policy_show)
|
|
392
|
+
|
|
393
|
+
p_cust = p_sub.add_parser("customize", help="Scaffold a custom override.")
|
|
394
|
+
p_cust.add_argument("name", help="Name of the policy to override "
|
|
395
|
+
"(e.g. 'study', 'coding').")
|
|
396
|
+
p_cust.add_argument("--policies", default="policies")
|
|
397
|
+
p_cust.set_defaults(func=cmd_policy_customize)
|
|
398
|
+
|
|
399
|
+
p_val = p_sub.add_parser("validate", help="Validate policy files.")
|
|
400
|
+
p_val.add_argument("--policies", default="policies")
|
|
401
|
+
p_val.set_defaults(func=cmd_policy_validate)
|
|
402
|
+
|
|
403
|
+
# doctor
|
|
404
|
+
p_doc = sub.add_parser("doctor", help="Check setup is correct.")
|
|
405
|
+
p_doc.add_argument("--policies", default="policies")
|
|
406
|
+
p_doc.set_defaults(func=cmd_doctor)
|
|
407
|
+
|
|
408
|
+
args = parser.parse_args()
|
|
409
|
+
args.func(args)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
if __name__ == "__main__":
|
|
413
|
+
main()
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Consolidation — the brain's "sleep" phase for plug-play-memory.
|
|
3
|
+
|
|
4
|
+
Three background jobs, designed to be run periodically (e.g. once per day
|
|
5
|
+
or after every N events):
|
|
6
|
+
|
|
7
|
+
1. consolidate_tagged() — promote reinforced tagged facts to archival,
|
|
8
|
+
decay the rest. (Hippocampus → neocortex.)
|
|
9
|
+
2. reinforce_on_recall() — boost importance + recall_count when a fact
|
|
10
|
+
is retrieved. (Spacing effect.)
|
|
11
|
+
3. forget_unused() — prune low/normal facts not recalled in N days.
|
|
12
|
+
(Active forgetting.)
|
|
13
|
+
|
|
14
|
+
Together these make the memory self-tuning: facts the agent actually uses
|
|
15
|
+
get stronger; facts it never recalls fade. No LLM required.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .storage import MemoryStore
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Consolidator:
|
|
27
|
+
"""Background jobs that turn raw memory into tuned memory."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, store: MemoryStore):
|
|
30
|
+
self.store = store
|
|
31
|
+
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
# 1. Sleep consolidation — review tagged facts
|
|
34
|
+
# ------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def consolidate_tagged(self, agent_id: str) -> dict[str, int]:
|
|
37
|
+
"""
|
|
38
|
+
For each tagged fact:
|
|
39
|
+
- If recall_count > 0 → promote to archival, clear tag, boost importance
|
|
40
|
+
- If similar events happened recently → promote (pattern reinforcement)
|
|
41
|
+
- Else → decay importance; if it hits low and is old, forget it
|
|
42
|
+
"""
|
|
43
|
+
tagged = self.store.get_tagged(agent_id, older_than_hours=0)
|
|
44
|
+
stats = {"promoted": 0, "decayed": 0, "forgotten": 0, "skipped": 0}
|
|
45
|
+
|
|
46
|
+
for fact in tagged:
|
|
47
|
+
# Was it recalled?
|
|
48
|
+
if fact.recall_count > 0:
|
|
49
|
+
self.store.promote(fact.id, tier="archival")
|
|
50
|
+
self._boost_importance(fact.id)
|
|
51
|
+
stats["promoted"] += 1
|
|
52
|
+
self.store.audit.log(agent_id, "consolidation_promote", {
|
|
53
|
+
"fact_id": fact.id, "reason": "recalled",
|
|
54
|
+
"recall_count": fact.recall_count,
|
|
55
|
+
})
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
# Similar event in last 24h? (poor man's pattern detection)
|
|
59
|
+
if self._has_similar_recent(agent_id, fact, hours=24):
|
|
60
|
+
self.store.promote(fact.id, tier="archival")
|
|
61
|
+
stats["promoted"] += 1
|
|
62
|
+
self.store.audit.log(agent_id, "consolidation_promote", {
|
|
63
|
+
"fact_id": fact.id, "reason": "similar_recent_event",
|
|
64
|
+
})
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Otherwise decay
|
|
68
|
+
self.store.decay_importance(fact.id, levels=1)
|
|
69
|
+
refreshed = self._get_fact(fact.id)
|
|
70
|
+
if refreshed and refreshed.importance == "low":
|
|
71
|
+
age_hours = (time.time() - refreshed.created_at) / 3600
|
|
72
|
+
if age_hours > 48:
|
|
73
|
+
self.store.forget(agent_id, fact.id, hard=False)
|
|
74
|
+
stats["forgotten"] += 1
|
|
75
|
+
self.store.audit.log(agent_id, "consolidation_forget", {
|
|
76
|
+
"fact_id": fact.id, "reason": "low_salience_unused",
|
|
77
|
+
})
|
|
78
|
+
else:
|
|
79
|
+
stats["decayed"] += 1
|
|
80
|
+
else:
|
|
81
|
+
stats["decayed"] += 1
|
|
82
|
+
|
|
83
|
+
return stats
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
# 2. Recall reinforcement — spacing effect
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def reinforce_on_recall(self, fact_id: str) -> None:
|
|
90
|
+
"""Call this every time a fact is recalled."""
|
|
91
|
+
self.store.update_recall_stats(fact_id)
|
|
92
|
+
# Boost importance if recalled multiple times
|
|
93
|
+
fact = self._get_fact(fact_id)
|
|
94
|
+
if fact and fact.recall_count >= 3 and fact.importance == "normal":
|
|
95
|
+
self._boost_importance(fact_id)
|
|
96
|
+
elif fact and fact.recall_count >= 5 and fact.importance == "low":
|
|
97
|
+
self._boost_importance(fact_id)
|
|
98
|
+
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
# 3. Active forgetting — prune unused
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def forget_unused(self, agent_id: str) -> dict[str, int]:
|
|
104
|
+
"""
|
|
105
|
+
Active forgetting sweep. Brains don't keep unused memories forever.
|
|
106
|
+
- low importance, not recalled in 30 days → forget
|
|
107
|
+
- normal importance, not recalled in 90 days → decay to low
|
|
108
|
+
- high importance → never auto-forget (like childhood memories)
|
|
109
|
+
"""
|
|
110
|
+
stats = {"forgotten_low": 0, "decayed_normal": 0}
|
|
111
|
+
|
|
112
|
+
# Forget low-importance facts not recalled in 30 days
|
|
113
|
+
for fact in self.store.get_stale(agent_id, importance="low", days_since_recall=30):
|
|
114
|
+
self.store.forget(agent_id, fact.id, hard=False)
|
|
115
|
+
stats["forgotten_low"] += 1
|
|
116
|
+
self.store.audit.log(agent_id, "active_forgetting", {
|
|
117
|
+
"fact_id": fact.id, "reason": "low_importance_unused_30d",
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
# Decay normal-importance facts not recalled in 90 days
|
|
121
|
+
for fact in self.store.get_stale(agent_id, importance="normal", days_since_recall=90):
|
|
122
|
+
self.store.decay_importance(fact.id, levels=1)
|
|
123
|
+
stats["decayed_normal"] += 1
|
|
124
|
+
self.store.audit.log(agent_id, "active_decay", {
|
|
125
|
+
"fact_id": fact.id, "reason": "normal_importance_unused_90d",
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
return stats
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
# Helpers
|
|
132
|
+
# ------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def _get_fact(self, fact_id: str):
|
|
135
|
+
rows = self.store._conn.execute(
|
|
136
|
+
"SELECT * FROM facts WHERE id = ?", (fact_id,)
|
|
137
|
+
).fetchall()
|
|
138
|
+
if not rows:
|
|
139
|
+
return None
|
|
140
|
+
return self.store._row_to_fact(rows[0])
|
|
141
|
+
|
|
142
|
+
def _has_similar_recent(self, agent_id: str, fact, hours: int) -> bool:
|
|
143
|
+
"""Has an event with the same action happened in the last N hours?"""
|
|
144
|
+
cutoff = time.time() - (hours * 3600)
|
|
145
|
+
action = fact.metadata.get("action", "")
|
|
146
|
+
if not action:
|
|
147
|
+
return False
|
|
148
|
+
rows = self.store._conn.execute(
|
|
149
|
+
"""SELECT COUNT(*) as c FROM facts
|
|
150
|
+
WHERE agent_id = ? AND created_at >= ?
|
|
151
|
+
AND json_extract(metadata, '$.action') = ?
|
|
152
|
+
AND id != ?""",
|
|
153
|
+
(agent_id, cutoff, action, fact.id),
|
|
154
|
+
).fetchone()
|
|
155
|
+
return rows["c"] > 0
|
|
156
|
+
|
|
157
|
+
def _boost_importance(self, fact_id: str) -> None:
|
|
158
|
+
"""Boost one level: low → normal → high (H5 fix — direction was reversed)."""
|
|
159
|
+
# Order is low → normal → high. Boost moves TOWARD high (higher index).
|
|
160
|
+
order = ["low", "normal", "high"]
|
|
161
|
+
fact = self._get_fact(fact_id)
|
|
162
|
+
if not fact:
|
|
163
|
+
return
|
|
164
|
+
idx = order.index(fact.importance) if fact.importance in order else 1
|
|
165
|
+
new_idx = min(len(order) - 1, idx + 1) # +1 to move toward high (was -1, wrong direction)
|
|
166
|
+
if new_idx != idx:
|
|
167
|
+
conn = self.store._conn
|
|
168
|
+
conn.execute(
|
|
169
|
+
"UPDATE facts SET importance = ?, updated_at = ? WHERE id = ?",
|
|
170
|
+
(order[new_idx], time.time(), fact_id),
|
|
171
|
+
)
|
|
172
|
+
conn.commit()
|