memctrl 1.0.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.
memctrl/store.py ADDED
@@ -0,0 +1,461 @@
1
+ """MemCtrl — SQLite data layer.
2
+
3
+ Implements the core storage for memories, tree nodes, and trigger logs.
4
+ Tree node format adapted from PageIndex (VectifyAI):
5
+ {node_id, title, start_index, end_index, summary, sub_nodes[]}
6
+ We replace page references with memory metadata (layer, source, confidence).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sqlite3
13
+ import uuid
14
+ from contextlib import contextmanager
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timedelta
17
+ from pathlib import Path
18
+ from typing import List, Optional
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Data models
22
+ # ---------------------------------------------------------------------------
23
+
24
+ @dataclass
25
+ class Memory:
26
+ """A single memory fact stored in the system."""
27
+
28
+ id: str
29
+ layer: str # 'project' | 'session' | 'user'
30
+ content: str # the memory fact
31
+ source: str # where it came from
32
+ confidence: float # 1.0=explicit, 0.7=inferred, 0.5=mentioned
33
+ created_at: datetime
34
+ expires_at: Optional[datetime]
35
+ tags: List[str] = field(default_factory=list)
36
+
37
+ def to_dict(self) -> dict:
38
+ return {
39
+ "id": self.id,
40
+ "layer": self.layer,
41
+ "content": self.content,
42
+ "source": self.source,
43
+ "confidence": self.confidence,
44
+ "created_at": self.created_at.isoformat() if self.created_at else None,
45
+ "expires_at": self.expires_at.isoformat() if self.expires_at else None,
46
+ "tags": self.tags,
47
+ }
48
+
49
+ @classmethod
50
+ def from_row(cls, row: sqlite3.Row) -> "Memory":
51
+ return cls(
52
+ id=row["id"],
53
+ layer=row["layer"],
54
+ content=row["content"],
55
+ source=row["source"],
56
+ confidence=row["confidence"],
57
+ created_at=_parse_dt(row["created_at"]),
58
+ expires_at=_parse_dt(row["expires_at"]) if row["expires_at"] else None,
59
+ tags=json.loads(row["tags"]) if row["tags"] else [],
60
+ )
61
+
62
+
63
+ @dataclass
64
+ class TreeNode:
65
+ """Hierarchical tree node — PageIndex-adapted for memory.
66
+
67
+ PageIndex node format (VectifyAI):
68
+ {node_id, title, start_index, end_index, summary, sub_nodes[]}
69
+ Adaptation: replace page refs with (layer, memory_ids, confidence).
70
+ """
71
+
72
+ id: str
73
+ title: str # e.g. "tech_stack"
74
+ layer: str # project / session / user
75
+ summary: str # LLM-generated summary of this branch
76
+ memory_ids: List[str] = field(default_factory=list)
77
+ children: List["TreeNode"] = field(default_factory=list)
78
+ confidence: float = 1.0
79
+ last_updated: datetime = field(default_factory=datetime.now)
80
+
81
+ def is_leaf(self) -> bool:
82
+ return len(self.children) == 0
83
+
84
+ def all_memory_ids(self) -> List[str]:
85
+ """Collect all memory IDs in this subtree."""
86
+ result = list(self.memory_ids)
87
+ for child in self.children:
88
+ result.extend(child.all_memory_ids())
89
+ return result
90
+
91
+ def find_node(self, node_id: str) -> Optional["TreeNode"]:
92
+ if self.id == node_id:
93
+ return self
94
+ for child in self.children:
95
+ found = child.find_node(node_id)
96
+ if found:
97
+ return found
98
+ return None
99
+
100
+ def to_dict(self) -> dict:
101
+ return {
102
+ "id": self.id,
103
+ "title": self.title,
104
+ "layer": self.layer,
105
+ "summary": self.summary,
106
+ "memory_ids": self.memory_ids,
107
+ "children": [c.to_dict() for c in self.children],
108
+ "confidence": self.confidence,
109
+ "last_updated": self.last_updated.isoformat(),
110
+ }
111
+
112
+ @classmethod
113
+ def from_dict(cls, data: dict) -> "TreeNode":
114
+ return cls(
115
+ id=data["id"],
116
+ title=data["title"],
117
+ layer=data["layer"],
118
+ summary=data.get("summary", ""),
119
+ memory_ids=data.get("memory_ids", []),
120
+ children=[cls.from_dict(c) for c in data.get("children", [])],
121
+ confidence=data.get("confidence", 1.0),
122
+ last_updated=_parse_dt(data.get("last_updated")),
123
+ )
124
+
125
+
126
+ @dataclass
127
+ class TriggerLog:
128
+ """Audit trail entry for trigger executions."""
129
+
130
+ id: str
131
+ event: str
132
+ action: str
133
+ memories_affected: List[str]
134
+ timestamp: datetime
135
+
136
+ def to_dict(self) -> dict:
137
+ return {
138
+ "id": self.id,
139
+ "event": self.event,
140
+ "action": self.action,
141
+ "memories_affected": self.memories_affected,
142
+ "timestamp": self.timestamp.isoformat() if self.timestamp else None,
143
+ }
144
+
145
+ @classmethod
146
+ def from_row(cls, row: sqlite3.Row) -> "TriggerLog":
147
+ return cls(
148
+ id=row["id"],
149
+ event=row["event"],
150
+ action=row["action"],
151
+ memories_affected=json.loads(row["memories_affected"]) if row["memories_affected"] else [],
152
+ timestamp=_parse_dt(row["timestamp"]),
153
+ )
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Helpers
158
+ # ---------------------------------------------------------------------------
159
+
160
+ def _default_db_path() -> str:
161
+ """Default SQLite DB path: ~/.memctrl/memories.db"""
162
+ p = Path.home() / ".memctrl" / "memories.db"
163
+ p.parent.mkdir(parents=True, exist_ok=True)
164
+ return str(p)
165
+
166
+
167
+ def _parse_dt(value) -> datetime:
168
+ """Parse datetime from ISO string or return now."""
169
+ if value is None:
170
+ return datetime.now()
171
+ if isinstance(value, datetime):
172
+ return value
173
+ if isinstance(value, str):
174
+ # Try various ISO formats
175
+ for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
176
+ try:
177
+ return datetime.strptime(value.split("+")[0].split("Z")[0], fmt)
178
+ except ValueError:
179
+ continue
180
+ return datetime.now()
181
+
182
+
183
+ def _now_iso() -> str:
184
+ return datetime.now().isoformat()
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Store
189
+ # ---------------------------------------------------------------------------
190
+
191
+ class MemoryStore:
192
+ """SQLite-backed store for memories, tree nodes, and trigger logs."""
193
+
194
+ def __init__(self, db_path: Optional[str] = None):
195
+ self.db_path = db_path or _default_db_path()
196
+ self._init_db()
197
+
198
+ # --- Connection management ---
199
+
200
+ @contextmanager
201
+ def _connect(self):
202
+ conn = sqlite3.connect(self.db_path)
203
+ conn.row_factory = sqlite3.Row
204
+ try:
205
+ yield conn
206
+ finally:
207
+ conn.close()
208
+
209
+ # --- Schema ---
210
+
211
+ def _init_db(self) -> None:
212
+ with self._connect() as conn:
213
+ conn.executescript(
214
+ """
215
+ CREATE TABLE IF NOT EXISTS memories (
216
+ id TEXT PRIMARY KEY,
217
+ layer TEXT NOT NULL,
218
+ content TEXT NOT NULL,
219
+ source TEXT,
220
+ confidence REAL DEFAULT 1.0,
221
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
222
+ expires_at TIMESTAMP,
223
+ tags TEXT
224
+ );
225
+
226
+ CREATE TABLE IF NOT EXISTS tree_nodes (
227
+ id TEXT PRIMARY KEY,
228
+ parent_id TEXT REFERENCES tree_nodes(id),
229
+ layer TEXT NOT NULL,
230
+ title TEXT NOT NULL,
231
+ summary TEXT,
232
+ memory_ids TEXT,
233
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
234
+ );
235
+
236
+ CREATE TABLE IF NOT EXISTS triggers_log (
237
+ id TEXT PRIMARY KEY,
238
+ event TEXT NOT NULL,
239
+ action TEXT NOT NULL,
240
+ memories_affected TEXT,
241
+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
242
+ );
243
+
244
+ CREATE INDEX IF NOT EXISTS idx_memories_layer ON memories(layer);
245
+ CREATE INDEX IF NOT EXISTS idx_memories_expires ON memories(expires_at);
246
+ CREATE INDEX IF NOT EXISTS idx_tree_parent ON tree_nodes(parent_id);
247
+ CREATE INDEX IF NOT EXISTS idx_tree_layer ON tree_nodes(layer);
248
+ CREATE INDEX IF NOT EXISTS idx_triggers_ts ON triggers_log(timestamp);
249
+ """
250
+ )
251
+ conn.commit()
252
+
253
+ # --- Memory CRUD ---
254
+
255
+ def insert_memory(
256
+ self,
257
+ layer: str,
258
+ content: str,
259
+ source: str = "manual",
260
+ confidence: float = 1.0,
261
+ tags: Optional[List[str]] = None,
262
+ expires_at: Optional[datetime] = None,
263
+ ) -> str:
264
+ mid = str(uuid.uuid4())
265
+ with self._connect() as conn:
266
+ conn.execute(
267
+ """INSERT INTO memories (id, layer, content, source, confidence,
268
+ created_at, expires_at, tags)
269
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
270
+ (mid, layer, content, source, confidence,
271
+ _now_iso(),
272
+ expires_at.isoformat() if expires_at else None,
273
+ json.dumps(tags or [])),
274
+ )
275
+ conn.commit()
276
+ return mid
277
+
278
+ def get_memory(self, id: str) -> Optional[Memory]:
279
+ with self._connect() as conn:
280
+ row = conn.execute(
281
+ "SELECT * FROM memories WHERE id = ?", (id,)
282
+ ).fetchone()
283
+ return Memory.from_row(row) if row else None
284
+
285
+ def list_memories(self, layer: Optional[str] = None) -> List[Memory]:
286
+ with self._connect() as conn:
287
+ if layer:
288
+ rows = conn.execute(
289
+ "SELECT * FROM memories WHERE layer = ? ORDER BY created_at DESC",
290
+ (layer,),
291
+ ).fetchall()
292
+ else:
293
+ rows = conn.execute(
294
+ "SELECT * FROM memories ORDER BY created_at DESC"
295
+ ).fetchall()
296
+ return [Memory.from_row(r) for r in rows]
297
+
298
+ def delete_memory(self, id: str) -> bool:
299
+ with self._connect() as conn:
300
+ cur = conn.execute("DELETE FROM memories WHERE id = ?", (id,))
301
+ conn.commit()
302
+ return cur.rowcount > 0
303
+
304
+ def update_memory_layer(self, id: str, new_layer: str) -> bool:
305
+ with self._connect() as conn:
306
+ cur = conn.execute(
307
+ "UPDATE memories SET layer = ? WHERE id = ?",
308
+ (new_layer, id),
309
+ )
310
+ conn.commit()
311
+ return cur.rowcount > 0
312
+
313
+ # --- Expiration ---
314
+
315
+ def expire_old_memories(self) -> int:
316
+ """Delete memories where expires_at < now(). Returns count."""
317
+ with self._connect() as conn:
318
+ cur = conn.execute(
319
+ "DELETE FROM memories WHERE expires_at IS NOT NULL AND expires_at < ?",
320
+ (_now_iso(),),
321
+ )
322
+ conn.commit()
323
+ return cur.rowcount
324
+
325
+ # --- Consolidation ---
326
+
327
+ def consolidate(self, from_layer: str, to_layer: str) -> List[str]:
328
+ """Move all memories from from_layer to to_layer. Returns moved IDs."""
329
+ with self._connect() as conn:
330
+ rows = conn.execute(
331
+ "SELECT id FROM memories WHERE layer = ?", (from_layer,)
332
+ ).fetchall()
333
+ ids = [r["id"] for r in rows]
334
+ if ids:
335
+ placeholders = ",".join("?" * len(ids))
336
+ conn.execute(
337
+ f"UPDATE memories SET layer = ? WHERE id IN ({placeholders})",
338
+ (to_layer, *ids),
339
+ )
340
+ conn.commit()
341
+ return ids
342
+
343
+ # --- Tree nodes ---
344
+
345
+ def clear_tree_nodes(self) -> None:
346
+ with self._connect() as conn:
347
+ conn.execute("DELETE FROM tree_nodes")
348
+ conn.commit()
349
+
350
+ def insert_tree_node(
351
+ self, node: TreeNode, parent_id: Optional[str] = None
352
+ ) -> str:
353
+ with self._connect() as conn:
354
+ conn.execute(
355
+ """INSERT INTO tree_nodes (id, parent_id, layer, title, summary,
356
+ memory_ids, updated_at)
357
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
358
+ (node.id, parent_id, node.layer, node.title, node.summary,
359
+ json.dumps(node.memory_ids), _now_iso()),
360
+ )
361
+ conn.commit()
362
+ return node.id
363
+
364
+ def get_tree_nodes(self, layer: Optional[str] = None) -> List[dict]:
365
+ with self._connect() as conn:
366
+ if layer:
367
+ rows = conn.execute(
368
+ "SELECT * FROM tree_nodes WHERE layer = ?", (layer,)
369
+ ).fetchall()
370
+ else:
371
+ rows = conn.execute("SELECT * FROM tree_nodes").fetchall()
372
+ return [
373
+ {
374
+ "id": r["id"],
375
+ "parent_id": r["parent_id"],
376
+ "layer": r["layer"],
377
+ "title": r["title"],
378
+ "summary": r["summary"],
379
+ "memory_ids": json.loads(r["memory_ids"]) if r["memory_ids"] else [],
380
+ }
381
+ for r in rows
382
+ ]
383
+
384
+ def build_tree_from_nodes(self) -> Optional[TreeNode]:
385
+ """Rebuild TreeNode hierarchy from flat DB rows."""
386
+ nodes = self.get_tree_nodes()
387
+ if not nodes:
388
+ return None
389
+ by_id = {n["id"]: n for n in nodes}
390
+ children = {}
391
+ root_candidates = []
392
+ for n in nodes:
393
+ pid = n.get("parent_id")
394
+ if pid:
395
+ children.setdefault(pid, []).append(n)
396
+ else:
397
+ root_candidates.append(n)
398
+
399
+ def build(n: dict) -> TreeNode:
400
+ node = TreeNode(
401
+ id=n["id"],
402
+ title=n["title"],
403
+ layer=n["layer"],
404
+ summary=n.get("summary", ""),
405
+ memory_ids=n.get("memory_ids", []),
406
+ children=[build(c) for c in children.get(n["id"], [])],
407
+ )
408
+ return node
409
+
410
+ if not root_candidates:
411
+ return None
412
+ # Use first root as main root, wrap others under it
413
+ if len(root_candidates) == 1:
414
+ return build(root_candidates[0])
415
+ root = TreeNode(
416
+ id="root", title="Memory Tree", layer="root",
417
+ summary="Root of all memory layers",
418
+ children=[build(r) for r in root_candidates],
419
+ )
420
+ return root
421
+
422
+ # --- Trigger log ---
423
+
424
+ def log_trigger(self, event: str, action: str, memory_ids: List[str]) -> str:
425
+ tid = str(uuid.uuid4())
426
+ with self._connect() as conn:
427
+ conn.execute(
428
+ """INSERT INTO triggers_log (id, event, action,
429
+ memories_affected, timestamp)
430
+ VALUES (?, ?, ?, ?, ?)""",
431
+ (tid, event, action, json.dumps(memory_ids), _now_iso()),
432
+ )
433
+ conn.commit()
434
+ return tid
435
+
436
+ def get_trigger_log(self, limit: int = 50) -> List[TriggerLog]:
437
+ with self._connect() as conn:
438
+ rows = conn.execute(
439
+ "SELECT * FROM triggers_log ORDER BY timestamp DESC LIMIT ?",
440
+ (limit,),
441
+ ).fetchall()
442
+ return [TriggerLog.from_row(r) for r in rows]
443
+
444
+ # --- Stats ---
445
+
446
+ def stats(self) -> dict:
447
+ with self._connect() as conn:
448
+ mem_count = conn.execute(
449
+ "SELECT COUNT(*) FROM memories"
450
+ ).fetchone()[0]
451
+ node_count = conn.execute(
452
+ "SELECT COUNT(*) FROM tree_nodes"
453
+ ).fetchone()[0]
454
+ trigger_count = conn.execute(
455
+ "SELECT COUNT(*) FROM triggers_log"
456
+ ).fetchone()[0]
457
+ return {
458
+ "memories": mem_count,
459
+ "tree_nodes": node_count,
460
+ "triggers": trigger_count,
461
+ }
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: memctrl
3
+ description: >
4
+ Rule-governed memory layer for this project. Call memctrl to store,
5
+ retrieve, or update memory about this project, session, or user preferences.
6
+ Use this when:
7
+ - Starting a new session (load context)
8
+ - Making an architecture decision (store to project layer)
9
+ - Finishing work (trigger on_session_end)
10
+ - Asking "what did we decide about X" (query memory tree)
11
+ ---
12
+
13
+ # MemCtrl Memory System
14
+
15
+ ## When to Use
16
+
17
+ - **Session start**: Run `memctrl query "current project context"` for instant context
18
+ - **Architecture decisions**: Run `memctrl add --layer project "decided to use X because Y"`
19
+ - **Session end**: Run `memctrl trigger on_session_end` to consolidate memories
20
+ - **Any context question**: Run `memctrl query "<your question>"`
21
+
22
+ ## Memory Layers
23
+
24
+ | Layer | Purpose | Default Expiry |
25
+ |-------|---------|---------------|
26
+ | project | Architecture decisions, tech stack, ADRs | Never |
27
+ | session | Current task, WIP, what was done this session | 7 days |
28
+ | user | Personal preferences, working style, patterns | 90 days |
29
+
30
+ ## Key Commands
31
+
32
+ ```bash
33
+ # Query what you need
34
+ memctrl query "what is our tech stack?"
35
+ memctrl query "why did we choose PostgreSQL?"
36
+
37
+ # Store decisions
38
+ memctrl add "decided to use Firecracker for sandbox isolation" --layer project
39
+ memctrl add "currently implementing auth flow" --layer session
40
+ memctrl add "prefers async Python, minimal abstractions" --layer user
41
+
42
+ # Manage
43
+ memctrl tree # view full memory tree
44
+ memctrl trigger on_session_end # consolidate session memories
45
+ memctrl audit # review what was remembered/forgotten
46
+ ```
47
+
48
+ ## How Retrieval Works
49
+
50
+ MemCtrl uses tree-based reasoning (like PageIndex) instead of vector similarity:
51
+ - Memories are organized in a semantic tree: project/tech_stack/database
52
+ - When you query, the system reasons about which branches to explore
53
+ - Results include a trace: root → project → tech_stack → database
54
+ - No embeddings needed — pure structured reasoning
55
+
56
+ ## MCP Server
57
+
58
+ If the MCP server is running (`memctrl serve`), these tools are available:
59
+ - `memctrl_query(query, layer?)` → Retrieve memories with trace
60
+ - `memctrl_add(content, layer, source?)` → Store a memory
61
+ - `memctrl_trigger(event, context?)` → Fire a trigger
62
+ - `memctrl_tree()` → Get full memory tree
63
+ - `memctrl_audit(limit?)` → View audit log
File without changes