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.
@@ -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()