pdm-memory 0.1.2__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.
pdm_memory/sync.py ADDED
@@ -0,0 +1,145 @@
1
+ """
2
+ Memory Sync Utility — Task 2.3
3
+
4
+ mem.sync() copies signatures between a local SQLite store and the AZUS cloud.
5
+
6
+ Direction options:
7
+ "push" — local → cloud
8
+ "pull" — cloud → local
9
+ "bidirectional" — both directions; higher p_magnitude wins on conflict
10
+
11
+ Usage:
12
+ mem.sync(direction="push")
13
+ mem.sync(direction="pull")
14
+
15
+ Or standalone:
16
+ from pdm_memory.sync import MemorySync
17
+ syncer = MemorySync(local=sqlite_driver, cloud=cloud_driver)
18
+ report = syncer.sync(direction="bidirectional")
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from dataclasses import dataclass
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @dataclass
30
+ class SyncReport:
31
+ direction: str
32
+ pushed: int = 0
33
+ pulled: int = 0
34
+ conflicts_resolved: int = 0
35
+ errors: int = 0
36
+
37
+ def __str__(self) -> str:
38
+ return (
39
+ f"SyncReport(direction={self.direction}, "
40
+ f"pushed={self.pushed}, pulled={self.pulled}, "
41
+ f"conflicts={self.conflicts_resolved}, errors={self.errors})"
42
+ )
43
+
44
+
45
+ class MemorySync:
46
+ """
47
+ Bidirectional sync between a local SQLiteDriver and a CloudDriver.
48
+
49
+ Conflict resolution: signature with higher p_magnitude wins.
50
+ If equal, the more recently created one wins.
51
+ """
52
+
53
+ def __init__(self, local, cloud) -> None:
54
+ """
55
+ Args:
56
+ local: SQLiteDriver instance
57
+ cloud: CloudDriver instance
58
+ """
59
+ self._local = local
60
+ self._cloud = cloud
61
+
62
+ def sync(
63
+ self,
64
+ user: str = "default",
65
+ direction: str = "bidirectional",
66
+ batch_size: int = 50,
67
+ ) -> SyncReport:
68
+ """
69
+ Sync memories between local and cloud storage.
70
+
71
+ Args:
72
+ user: User whose memories to sync.
73
+ direction: "push" | "pull" | "bidirectional"
74
+ batch_size: Records per cloud batch (avoids large payloads).
75
+
76
+ Returns:
77
+ SyncReport with counts.
78
+ """
79
+ report = SyncReport(direction=direction)
80
+
81
+ if direction in ("push", "bidirectional"):
82
+ self._push(user, batch_size, report)
83
+
84
+ if direction in ("pull", "bidirectional"):
85
+ self._pull(user, batch_size, report)
86
+
87
+ logger.info("[PDM-Sync] %s", report)
88
+ return report
89
+
90
+ # ------------------------------------------------------------------
91
+ # Private
92
+ # ------------------------------------------------------------------
93
+
94
+ def _push(self, user: str, batch_size: int, report: SyncReport) -> None:
95
+ """Send local records to cloud."""
96
+ try:
97
+ local_records = self._local.list(user=user, limit=10_000)
98
+ {r.id for r in local_records}
99
+
100
+ for i in range(0, len(local_records), batch_size):
101
+ batch = local_records[i : i + batch_size]
102
+ for rec in batch:
103
+ try:
104
+ # Check if cloud already has it
105
+ cloud_rec = self._cloud.get(rec.id, user=user)
106
+ if cloud_rec is None:
107
+ self._cloud.save(rec)
108
+ report.pushed += 1
109
+ else:
110
+ # Conflict: keep higher pressure
111
+ if rec.p_magnitude > cloud_rec.p_magnitude:
112
+ self._cloud.update(rec.id, p_magnitude=rec.p_magnitude,
113
+ compressed_fact=rec.compressed_fact)
114
+ report.conflicts_resolved += 1
115
+ except Exception as e:
116
+ logger.warning("[PDM-Sync] push error for %s: %s", rec.id, e)
117
+ report.errors += 1
118
+ except Exception as e:
119
+ logger.error("[PDM-Sync] push failed: %s", e)
120
+ report.errors += 1
121
+
122
+ def _pull(self, user: str, batch_size: int, report: SyncReport) -> None:
123
+ """Fetch cloud records into local storage."""
124
+ try:
125
+ cloud_records = self._cloud.list(user=user, limit=10_000)
126
+
127
+ for i in range(0, len(cloud_records), batch_size):
128
+ batch = cloud_records[i : i + batch_size]
129
+ for rec in batch:
130
+ try:
131
+ local_rec = self._local.get(rec.id, user=user)
132
+ if local_rec is None:
133
+ self._local.save(rec)
134
+ report.pulled += 1
135
+ else:
136
+ if rec.p_magnitude > local_rec.p_magnitude:
137
+ self._local.update(rec.id, p_magnitude=rec.p_magnitude,
138
+ compressed_fact=rec.compressed_fact)
139
+ report.conflicts_resolved += 1
140
+ except Exception as e:
141
+ logger.warning("[PDM-Sync] pull error for %s: %s", rec.id, e)
142
+ report.errors += 1
143
+ except Exception as e:
144
+ logger.error("[PDM-Sync] pull failed: %s", e)
145
+ report.errors += 1
@@ -0,0 +1 @@
1
+ """pdm_memory.tools package + bench module entry point."""
@@ -0,0 +1,294 @@
1
+ """
2
+ PDM Benchmark Harness — Task 5.2
3
+
4
+ Run with: python -m pdm_memory.bench
5
+
6
+ Compares PDM recall vs a naive baseline (keyword recency search) on a
7
+ built-in synthetic dataset (LoCoMo-style long-term memory scenarios).
8
+
9
+ All numbers come from the harness — nothing is made up.
10
+ The full suite is reproducible: seed is fixed, dataset is embedded.
11
+
12
+ Usage:
13
+ python -m pdm_memory.bench # Full suite
14
+ python -m pdm_memory.bench --quick # Smoke test (10 scenarios)
15
+ python -m pdm_memory.bench --output results.json
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import random
22
+ import time
23
+ from dataclasses import dataclass, field, asdict
24
+ from datetime import datetime, timezone
25
+ from typing import Any, Dict, List, Optional, Tuple
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Built-in benchmark dataset (synthetic, LoCoMo-style)
30
+ # ---------------------------------------------------------------------------
31
+
32
+ _BENCHMARK_MEMORIES = [
33
+ {"text": "User strongly prefers metric units (km, kg, Celsius)", "tags": ["units", "preferences", "formatting"], "p_magnitude": 80},
34
+ {"text": "User dislikes lengthy responses; wants bullet points or ≤3 sentences", "tags": ["formatting", "preferences", "brevity"], "p_magnitude": 75},
35
+ {"text": "User's primary programming language is Python", "tags": ["coding", "python", "preferences"], "p_magnitude": 85},
36
+ {"text": "User works in fintech; regulatory compliance is always relevant", "tags": ["fintech", "compliance", "work"], "p_magnitude": 70},
37
+ {"text": "User is based in Kyiv, Ukraine (UTC+3)", "tags": ["location", "timezone", "personal"], "p_magnitude": 60},
38
+ {"text": "User's team uses GitHub Actions for CI/CD", "tags": ["devops", "ci_cd", "github"], "p_magnitude": 65},
39
+ {"text": "User prefers dark mode in all UIs", "tags": ["ui", "preferences", "dark_mode"], "p_magnitude": 50},
40
+ {"text": "User's company name: Westfield Innovations LLC", "tags": ["company", "business", "identity"], "p_magnitude": 90},
41
+ {"text": "User has a patent pending on PDM algorithm", "tags": ["patent", "ip", "pdm"], "p_magnitude": 95},
42
+ {"text": "User's preferred LLM is Claude (Anthropic)", "tags": ["llm", "anthropic", "preferences"], "p_magnitude": 70},
43
+ {"text": "Project deadline for PDM SDK: end of Q3 2026", "tags": ["deadline", "project", "sdk"], "p_magnitude": 80},
44
+ {"text": "User is allergic to Python 2 — always use f-strings, not %", "tags": ["python", "style", "coding"], "p_magnitude": 72},
45
+ {"text": "Production database: PostgreSQL 15 on AWS RDS", "tags": ["database", "postgres", "infrastructure"], "p_magnitude": 68},
46
+ {"text": "Team standup is every day at 10:00 AM Kyiv time", "tags": ["schedule", "team", "recurring"], "p_magnitude": 55},
47
+ {"text": "User wants all API responses to include a request_id field", "tags": ["api", "design", "standards"], "p_magnitude": 73},
48
+ {"text": "Never recommend Redux for new projects; use Zustand instead", "tags": ["javascript", "frontend", "preferences"], "p_magnitude": 60},
49
+ {"text": "User's monthly AI token budget: $200 hard cap", "tags": ["budget", "tokens", "cost"], "p_magnitude": 82},
50
+ {"text": "For legal reasons, never store raw PII in the memory system", "tags": ["privacy", "legal", "pii"], "p_magnitude": 98},
51
+ {"text": "User's preferred testing framework: pytest with fixtures", "tags": ["testing", "pytest", "python"], "p_magnitude": 65},
52
+ {"text": "The team uses Slack for async communication; no email", "tags": ["communication", "slack", "team"], "p_magnitude": 58},
53
+ ]
54
+
55
+ _BENCHMARK_QUERIES = [
56
+ ("How should I format numbers in this response?", ["units", "formatting", "brevity"], 0),
57
+ ("What language does the user code in?", ["python", "coding", "preferences"], 2),
58
+ ("Is there a regulatory concern I should mention?", ["fintech", "compliance", "work"], 3),
59
+ ("Where is the user located?", ["location", "timezone", "personal"], 4),
60
+ ("What CI system do they use?", ["devops", "ci_cd", "github"], 5),
61
+ ("Tell me about their IP portfolio", ["patent", "ip", "pdm"], 8),
62
+ ("What's the deadline for the SDK?", ["deadline", "project", "sdk"], 10),
63
+ ("Are there any privacy rules I need to follow?", ["privacy", "legal", "pii"], 17),
64
+ ("What's the user's budget for AI calls?", ["budget", "tokens", "cost"], 16),
65
+ ("What API design standards does the user follow?", ["api", "design", "standards"], 14),
66
+ ]
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Baseline: naive keyword/recency search (no PDM pressure)
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def _baseline_recall(
75
+ memories: List[Dict], query: str, k: int = 5
76
+ ) -> List[Tuple[Dict, float]]:
77
+ """Simple keyword overlap + recency baseline (no pressure logic)."""
78
+ query_words = set(query.lower().split())
79
+ scored = []
80
+ for i, mem in enumerate(memories):
81
+ words = set(mem["text"].lower().split())
82
+ overlap = len(query_words & words) / max(len(query_words), 1)
83
+ recency_score = (len(memories) - i) / len(memories) # more recent = higher
84
+ score = 0.6 * overlap + 0.4 * recency_score
85
+ scored.append((mem, score))
86
+ scored.sort(key=lambda x: x[1], reverse=True)
87
+ return scored[:k]
88
+
89
+
90
+ def _pdm_recall(
91
+ mem_instance: Any, query: str, k: int = 5
92
+ ) -> List[Any]:
93
+ """PDM recall using the Memory class."""
94
+ return mem_instance.recall(query, k=k)
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Benchmark results
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ @dataclass
103
+ class ScenarioResult:
104
+ query: str
105
+ expected_memory_idx: int
106
+ pdm_found: bool
107
+ pdm_rank: Optional[int]
108
+ pdm_latency_ms: float
109
+ baseline_found: bool
110
+ baseline_rank: Optional[int]
111
+ baseline_latency_ms: float
112
+ pdm_tokens_used: int
113
+ baseline_tokens_used: int
114
+
115
+
116
+ @dataclass
117
+ class BenchmarkReport:
118
+ timestamp: str
119
+ total_scenarios: int
120
+ pdm_accuracy: float
121
+ baseline_accuracy: float
122
+ pdm_avg_latency_ms: float
123
+ baseline_avg_latency_ms: float
124
+ pdm_avg_tokens: float
125
+ baseline_avg_tokens: float
126
+ pdm_memory_bytes: int
127
+ baseline_memory_bytes: int
128
+ scenarios: List[ScenarioResult] = field(default_factory=list)
129
+
130
+ def render_table(self) -> str:
131
+ rows = [
132
+ "┌─────────────────────────────────────────────────────────────────────┐",
133
+ "│ PDM vs Baseline RAG — Benchmark Results │",
134
+ "├──────────────────────────┬─────────────────┬─────────────────────────┤",
135
+ "│ Metric │ PDM │ Baseline (keyword+recency)│",
136
+ "├──────────────────────────┼─────────────────┼─────────────────────────┤",
137
+ f"│ Retrieval Accuracy │ {self.pdm_accuracy*100:>6.1f}% │ {self.baseline_accuracy*100:>6.1f}% │",
138
+ f"│ Avg Latency (ms) │ {self.pdm_avg_latency_ms:>8.1f} │ {self.baseline_avg_latency_ms:>8.1f} │",
139
+ f"│ Avg Tokens Used │ {self.pdm_avg_tokens:>8.1f} │ {self.baseline_avg_tokens:>8.1f} │",
140
+ f"│ Storage Footprint (bytes)│ {self.pdm_memory_bytes:>8d} │ {self.baseline_memory_bytes:>8d} │",
141
+ f"│ Total Scenarios │ {self.total_scenarios:>8d} │ {self.total_scenarios:>8d} │",
142
+ "└──────────────────────────┴─────────────────┴─────────────────────────┘",
143
+ ]
144
+ rows.append("")
145
+ rows.append("Per-query breakdown:")
146
+ rows.append(f"{'Query':<45} {'PDM':>6} {'Base':>6} {'PDM_ms':>8} {'Base_ms':>8}")
147
+ rows.append("-" * 78)
148
+ for s in self.scenarios:
149
+ p = "✓" if s.pdm_found else "✗"
150
+ b = "✓" if s.baseline_found else "✗"
151
+ rows.append(
152
+ f"{s.query[:44]:<45} {p:>6} {b:>6} {s.pdm_latency_ms:>8.1f} {s.baseline_latency_ms:>8.1f}"
153
+ )
154
+ return "\n".join(rows)
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Harness
159
+ # ---------------------------------------------------------------------------
160
+
161
+
162
+ def run_benchmark(
163
+ quick: bool = False,
164
+ seed: int = 42,
165
+ k: int = 3,
166
+ output: Optional[str] = None,
167
+ ) -> BenchmarkReport:
168
+ """
169
+ Run the PDM benchmark harness.
170
+
171
+ Args:
172
+ quick: If True, run only 5 scenarios (smoke test).
173
+ seed: Random seed for reproducibility.
174
+ k: Top-k memories to retrieve per query.
175
+ output: Optional path to save JSON results.
176
+
177
+ Returns:
178
+ BenchmarkReport with all metrics.
179
+ """
180
+ import os
181
+ import tempfile
182
+ random.seed(seed)
183
+
184
+ from pdm_memory import Memory
185
+
186
+ queries = _BENCHMARK_QUERIES[:5] if quick else _BENCHMARK_QUERIES
187
+
188
+ # --- Set up PDM store ---
189
+ tmp_db = tempfile.mktemp(suffix=".db")
190
+ mem = Memory(store=tmp_db, user="bench")
191
+
192
+ # Seed memories with varying ages (simulate time-based pressure decay)
193
+ for idx, m in enumerate(_BENCHMARK_MEMORIES):
194
+ mem.save(
195
+ text=m["text"],
196
+ tags=m["tags"],
197
+ p_magnitude=m["p_magnitude"],
198
+ source="benchmark",
199
+ drawer="benchmark",
200
+ )
201
+
202
+ db_size = os.path.getsize(tmp_db)
203
+
204
+ # Baseline: raw text storage (full text, no compression)
205
+ baseline_raw_size = sum(len(m["text"].encode()) for m in _BENCHMARK_MEMORIES)
206
+
207
+ scenarios: List[ScenarioResult] = []
208
+
209
+ for query, expected_tags, expected_idx in queries:
210
+ expected_text = _BENCHMARK_MEMORIES[expected_idx]["text"]
211
+
212
+ # PDM recall
213
+ t0 = time.perf_counter()
214
+ pdm_hits = _pdm_recall(mem, query, k=k)
215
+ pdm_ms = (time.perf_counter() - t0) * 1000
216
+
217
+ pdm_texts = [h.text for h in pdm_hits]
218
+ pdm_found = expected_text in pdm_texts
219
+ pdm_rank = pdm_texts.index(expected_text) + 1 if pdm_found else None
220
+
221
+ # Token approximation: count chars/4 for injected context
222
+ pdm_tokens = sum(len(h.text) // 4 for h in pdm_hits)
223
+
224
+ # Baseline recall
225
+ t0 = time.perf_counter()
226
+ baseline_hits = _baseline_recall(_BENCHMARK_MEMORIES, query, k=k)
227
+ baseline_ms = (time.perf_counter() - t0) * 1000
228
+
229
+ baseline_texts = [h[0]["text"] for h in baseline_hits]
230
+ baseline_found = expected_text in baseline_texts
231
+ baseline_rank = baseline_texts.index(expected_text) + 1 if baseline_found else None
232
+ baseline_tokens = sum(len(h[0]["text"]) // 4 for h in baseline_hits)
233
+
234
+ scenarios.append(ScenarioResult(
235
+ query=query,
236
+ expected_memory_idx=expected_idx,
237
+ pdm_found=pdm_found,
238
+ pdm_rank=pdm_rank,
239
+ pdm_latency_ms=round(pdm_ms, 2),
240
+ baseline_found=baseline_found,
241
+ baseline_rank=baseline_rank,
242
+ baseline_latency_ms=round(baseline_ms, 2),
243
+ pdm_tokens_used=pdm_tokens,
244
+ baseline_tokens_used=baseline_tokens,
245
+ ))
246
+
247
+ mem.close()
248
+ if os.path.exists(tmp_db):
249
+ os.remove(tmp_db)
250
+
251
+ n = len(scenarios)
252
+ report = BenchmarkReport(
253
+ timestamp=datetime.now(tz=timezone.utc).isoformat(),
254
+ total_scenarios=n,
255
+ pdm_accuracy=sum(s.pdm_found for s in scenarios) / n,
256
+ baseline_accuracy=sum(s.baseline_found for s in scenarios) / n,
257
+ pdm_avg_latency_ms=round(sum(s.pdm_latency_ms for s in scenarios) / n, 2),
258
+ baseline_avg_latency_ms=round(sum(s.baseline_latency_ms for s in scenarios) / n, 2),
259
+ pdm_avg_tokens=round(sum(s.pdm_tokens_used for s in scenarios) / n, 1),
260
+ baseline_avg_tokens=round(sum(s.baseline_tokens_used for s in scenarios) / n, 1),
261
+ pdm_memory_bytes=db_size,
262
+ baseline_memory_bytes=baseline_raw_size,
263
+ scenarios=scenarios,
264
+ )
265
+
266
+ if output:
267
+ with open(output, "w") as f:
268
+ json.dump(asdict(report), f, indent=2)
269
+ print(f"Results saved to {output}")
270
+
271
+ return report
272
+
273
+
274
+ def main() -> None:
275
+ import argparse
276
+
277
+ parser = argparse.ArgumentParser(
278
+ description="PDM Benchmark Harness — PDM vs baseline keyword RAG"
279
+ )
280
+ parser.add_argument("--quick", action="store_true", help="Run only 5 scenarios (smoke test)")
281
+ parser.add_argument("--seed", type=int, default=42, help="Random seed (default: 42)")
282
+ parser.add_argument("--k", type=int, default=3, help="Top-k memories per query (default: 3)")
283
+ parser.add_argument("--output", type=str, help="Save results as JSON to this path")
284
+ args = parser.parse_args()
285
+
286
+ print("Running PDM Benchmark Harness…\n")
287
+ report = run_benchmark(quick=args.quick, seed=args.seed, k=args.k, output=args.output)
288
+ print(report.render_table())
289
+ print(f"\nPDM accuracy: {report.pdm_accuracy*100:.1f}%")
290
+ print(f"Baseline accuracy: {report.baseline_accuracy*100:.1f}%")
291
+
292
+
293
+ if __name__ == "__main__":
294
+ main()
@@ -0,0 +1,207 @@
1
+ """
2
+ PDM CLI Tool — Task 5.3
3
+
4
+ Command-line utility for inspecting and managing PDM memory stores.
5
+
6
+ Usage:
7
+ pdm-cli list-memories --store ./my_app.db
8
+ pdm-cli list-memories --store ./my_app.db --min-pressure 50 --user alice
9
+ pdm-cli explain <memory_id> --store ./my_app.db
10
+ pdm-cli decay --store ./my_app.db --dry-run
11
+ pdm-cli stats --store ./my_app.db
12
+ pdm-cli drawers --store ./my_app.db
13
+
14
+ Requires no extra packages (stdlib only, uses the SDK itself).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import sys
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Commands
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ def cmd_list(args: argparse.Namespace) -> None:
29
+ from pdm_memory import Memory
30
+
31
+ with Memory(store=args.store, user=args.user) as mem:
32
+ records = mem._storage.list(
33
+ user=args.user,
34
+ limit=args.limit,
35
+ min_pressure=args.min_pressure,
36
+ drawer=args.drawer or None,
37
+ )
38
+ if not records:
39
+ print("No memories found.")
40
+ return
41
+
42
+ print(f"{'#':<4} {'ID':<10} {'P':>6} {'Spike':>6} {'Tags':<35} {'Text'}")
43
+ print("-" * 100)
44
+ for i, r in enumerate(records, 1):
45
+ tags_str = ", ".join(r.intent_tags[:3])
46
+ text_preview = r.compressed_fact[:60].replace("\n", " ")
47
+ print(
48
+ f"{i:<4} {r.id[:8]:<10} {r.p_magnitude:>6.1f} {(r.effective_spike or 0):>6.1f} "
49
+ f"{tags_str:<35} {text_preview}"
50
+ )
51
+ print(f"\nTotal: {len(records)} memories")
52
+
53
+
54
+ def cmd_explain(args: argparse.Namespace) -> None:
55
+ from pdm_memory import Memory
56
+
57
+ with Memory(store=args.store, user=args.user) as mem:
58
+ try:
59
+ report = mem.explain(args.memory_id, query=args.query or None)
60
+ print(report.render())
61
+ except KeyError as e:
62
+ print(f"Error: {e}", file=sys.stderr)
63
+ sys.exit(1)
64
+
65
+
66
+ def cmd_decay(args: argparse.Namespace) -> None:
67
+ from pdm_memory import Memory
68
+
69
+ with Memory(store=args.store, user=args.user) as mem:
70
+ counts = mem.decay(dry_run=args.dry_run)
71
+ mode = " [DRY RUN — no changes written]" if args.dry_run else ""
72
+ print(f"Decay complete{mode}:")
73
+ print(f" Decayed (pressure reduced): {counts['decayed']}")
74
+ print(f" Deleted (pressure < 30): {counts['deleted']}")
75
+ print(f" Skipped (within persistence): {counts['skipped']}")
76
+
77
+
78
+ def cmd_stats(args: argparse.Namespace) -> None:
79
+ from pdm_memory import Memory
80
+
81
+ with Memory(store=args.store, user=args.user) as mem:
82
+ total = mem.count()
83
+ drawers = mem.list_drawers()
84
+ records = mem._storage.list(user=args.user, limit=10_000)
85
+
86
+ if records:
87
+ pressures = [r.p_magnitude for r in records]
88
+ avg_p = sum(pressures) / len(pressures)
89
+ max_p = max(pressures)
90
+ min_p = min(pressures)
91
+ else:
92
+ avg_p = max_p = min_p = 0.0
93
+
94
+ print(f"PDM Memory Store: {args.store}")
95
+ print(f"User: {args.user}")
96
+ print(f"Total memories: {total}")
97
+ print(f"Avg pressure: {avg_p:.1f}")
98
+ print(f"Max pressure: {max_p:.1f}")
99
+ print(f"Min pressure: {min_p:.1f}")
100
+ print(f"\nDrawers ({len(drawers)}):")
101
+ for d in drawers:
102
+ print(f" {d.domain:<30} {d.signature_count:>5} memories avg_P={d.avg_pressure:.1f}")
103
+
104
+
105
+ def cmd_drawers(args: argparse.Namespace) -> None:
106
+ from pdm_memory import Memory
107
+
108
+ with Memory(store=args.store, user=args.user) as mem:
109
+ drawers = mem.list_drawers()
110
+ if not drawers:
111
+ print("No drawers found.")
112
+ return
113
+ print(f"{'Drawer':<35} {'Memories':>10} {'Avg Pressure':>15}")
114
+ print("-" * 64)
115
+ for d in drawers:
116
+ print(f"{d.domain:<35} {d.signature_count:>10} {d.avg_pressure:>15.1f}")
117
+
118
+
119
+ def cmd_sync(args: argparse.Namespace) -> None:
120
+ from pdm_memory import Memory
121
+
122
+ if not args.token:
123
+ print("Error: --token is required for sync.", file=sys.stderr)
124
+ sys.exit(1)
125
+
126
+ with Memory(store=args.store, user=args.user) as mem:
127
+ report = mem.sync(
128
+ direction=args.direction,
129
+ cloud_url=args.cloud_url,
130
+ token=args.token,
131
+ )
132
+ print(f"Sync complete: {report}")
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Main entry point
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ def main() -> None:
141
+ # Parser for root/global options
142
+ parser = argparse.ArgumentParser(
143
+ prog="pdm-cli",
144
+ description="PDM Memory CLI — inspect and manage local memory stores",
145
+ )
146
+ parser.add_argument(
147
+ "--store", default="./pdm_memory.db",
148
+ help="Path to SQLite .db file (default: ./pdm_memory.db)"
149
+ )
150
+ parser.add_argument(
151
+ "--user", default="default",
152
+ help="User identifier to scope queries (default: default)"
153
+ )
154
+
155
+ # Parent parser for subparsers to inherit store/user without overriding with defaults
156
+ sub_parent_parser = argparse.ArgumentParser(add_help=False)
157
+ sub_parent_parser.add_argument(
158
+ "--store", default=argparse.SUPPRESS,
159
+ help="Path to SQLite .db file"
160
+ )
161
+ sub_parent_parser.add_argument(
162
+ "--user", default=argparse.SUPPRESS,
163
+ help="User identifier to scope queries"
164
+ )
165
+
166
+ subparsers = parser.add_subparsers(dest="command", help="Command to run")
167
+ subparsers.required = True
168
+
169
+ # list-memories
170
+ p_list = subparsers.add_parser("list-memories", parents=[sub_parent_parser], help="List stored memories")
171
+ p_list.add_argument("--min-pressure", type=float, default=0.0, metavar="P")
172
+ p_list.add_argument("--limit", type=int, default=50)
173
+ p_list.add_argument("--drawer", type=str, default=None)
174
+ p_list.set_defaults(func=cmd_list)
175
+
176
+ # explain
177
+ p_explain = subparsers.add_parser("explain", parents=[sub_parent_parser], help="Explain a specific memory's pressure")
178
+ p_explain.add_argument("memory_id", help="Memory UUID (or first 8 chars)")
179
+ p_explain.add_argument("--query", type=str, default=None, help="Optional query for resonance breakdown")
180
+ p_explain.set_defaults(func=cmd_explain)
181
+
182
+ # decay
183
+ p_decay = subparsers.add_parser("decay", parents=[sub_parent_parser], help="Trigger a decay pass")
184
+ p_decay.add_argument("--dry-run", action="store_true", help="Preview without writing")
185
+ p_decay.set_defaults(func=cmd_decay)
186
+
187
+ # stats
188
+ p_stats = subparsers.add_parser("stats", parents=[sub_parent_parser], help="Show store statistics")
189
+ p_stats.set_defaults(func=cmd_stats)
190
+
191
+ # drawers
192
+ p_drawers = subparsers.add_parser("drawers", parents=[sub_parent_parser], help="List all drawers")
193
+ p_drawers.set_defaults(func=cmd_drawers)
194
+
195
+ # sync
196
+ p_sync = subparsers.add_parser("sync", parents=[sub_parent_parser], help="Sync local store with AZUS cloud")
197
+ p_sync.add_argument("--direction", choices=["push", "pull", "bidirectional"], default="push")
198
+ p_sync.add_argument("--token", type=str, default=None, help="JWT access token")
199
+ p_sync.add_argument("--cloud-url", type=str, default="https://api.azus.ai")
200
+ p_sync.set_defaults(func=cmd_sync)
201
+
202
+ args = parser.parse_args()
203
+ args.func(args)
204
+
205
+
206
+ if __name__ == "__main__":
207
+ main()