mempalace-code 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.
mempalace/layers.py ADDED
@@ -0,0 +1,515 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ layers.py — 4-Layer Memory Stack for mempalace
4
+ ===================================================
5
+
6
+ Load only what you need, when you need it.
7
+
8
+ Layer 0: Identity (~100 tokens) — Always loaded. "Who am I?"
9
+ Layer 1: Essential Story (~500-800) — Always loaded. Top moments from the palace.
10
+ Layer 2: On-Demand (~200-500 each) — Loaded when a topic/wing comes up.
11
+ Layer 3: Deep Search (unlimited) — Full semantic search.
12
+
13
+ Wake-up cost: ~600-900 tokens (L0+L1). Leaves 95%+ of context free.
14
+
15
+ Reads from the palace drawer store (LanceDB or ChromaDB)
16
+ and ~/.mempalace/identity.txt.
17
+ """
18
+
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+ from collections import defaultdict
23
+
24
+ from .storage import open_store
25
+
26
+ from .config import MempalaceConfig
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Layer 0 — Identity
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ class Layer0:
35
+ """
36
+ ~100 tokens. Always loaded.
37
+ Reads from ~/.mempalace/identity.txt — a plain-text file the user writes.
38
+
39
+ Example identity.txt:
40
+ I am Atlas, a personal AI assistant for Alice.
41
+ Traits: warm, direct, remembers everything.
42
+ People: Alice (creator), Bob (Alice's partner).
43
+ Project: A journaling app that helps people process emotions.
44
+ """
45
+
46
+ def __init__(self, identity_path: str = None):
47
+ if identity_path is None:
48
+ identity_path = os.path.expanduser("~/.mempalace/identity.txt")
49
+ self.path = identity_path
50
+ self._text = None
51
+
52
+ def render(self) -> str:
53
+ """Return the identity text, or a sensible default."""
54
+ if self._text is not None:
55
+ return self._text
56
+
57
+ if os.path.exists(self.path):
58
+ with open(self.path, "r") as f:
59
+ self._text = f.read().strip()
60
+ else:
61
+ self._text = (
62
+ "## L0 — IDENTITY\nNo identity configured. Create ~/.mempalace/identity.txt"
63
+ )
64
+
65
+ return self._text
66
+
67
+ def token_estimate(self) -> int:
68
+ return len(self.render()) // 4
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Layer 1 — Essential Story (auto-generated from palace)
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ class Layer1:
77
+ """
78
+ ~500-800 tokens. Always loaded.
79
+ Auto-generated from the highest-weight / most-recent drawers in the palace.
80
+ Groups by room, picks the top N moments, compresses to a compact summary.
81
+ """
82
+
83
+ MAX_DRAWERS = 15 # at most 15 moments in wake-up
84
+ MAX_CHARS = 3200 # hard cap on total L1 text (~800 tokens)
85
+
86
+ def __init__(self, palace_path: str = None, wing: str = None):
87
+ cfg = MempalaceConfig()
88
+ self.palace_path = palace_path or cfg.palace_path
89
+ self.wing = wing
90
+
91
+ def generate(self) -> str:
92
+ """Pull top drawers from the palace and format as compact L1 text."""
93
+ try:
94
+ store = open_store(self.palace_path, create=False)
95
+ col = store
96
+ except Exception:
97
+ return "## L1 — No palace found. Run: mempalace mine <dir>"
98
+
99
+ # Fetch all drawers in batches to avoid SQLite variable limit (~999)
100
+ _BATCH = 500
101
+ docs, metas = [], []
102
+ offset = 0
103
+ while True:
104
+ kwargs = {"include": ["documents", "metadatas"], "limit": _BATCH, "offset": offset}
105
+ if self.wing:
106
+ kwargs["where"] = {"wing": self.wing}
107
+ try:
108
+ batch = col.get(**kwargs)
109
+ except Exception:
110
+ break
111
+ batch_docs = batch.get("documents", [])
112
+ batch_metas = batch.get("metadatas", [])
113
+ if not batch_docs:
114
+ break
115
+ docs.extend(batch_docs)
116
+ metas.extend(batch_metas)
117
+ offset += len(batch_docs)
118
+ if len(batch_docs) < _BATCH:
119
+ break
120
+
121
+ if not docs:
122
+ return "## L1 — No memories yet."
123
+
124
+ # Score each drawer: prefer high importance, recent filing
125
+ scored = []
126
+ for doc, meta in zip(docs, metas):
127
+ importance = 3
128
+ # Try multiple metadata keys that might carry weight info
129
+ for key in ("importance", "emotional_weight", "weight"):
130
+ val = meta.get(key)
131
+ if val is not None:
132
+ try:
133
+ importance = float(val)
134
+ except (ValueError, TypeError):
135
+ pass
136
+ break
137
+ scored.append((importance, meta, doc))
138
+
139
+ # Sort by importance descending, take top N
140
+ scored.sort(key=lambda x: x[0], reverse=True)
141
+ top = scored[: self.MAX_DRAWERS]
142
+
143
+ # Group by room for readability
144
+ by_room = defaultdict(list)
145
+ for imp, meta, doc in top:
146
+ room = meta.get("room", "general")
147
+ by_room[room].append((imp, meta, doc))
148
+
149
+ # Build compact text
150
+ lines = ["## L1 — ESSENTIAL STORY"]
151
+
152
+ total_len = 0
153
+ for room, entries in sorted(by_room.items()):
154
+ room_line = f"\n[{room}]"
155
+ lines.append(room_line)
156
+ total_len += len(room_line)
157
+
158
+ for imp, meta, doc in entries:
159
+ source = Path(meta.get("source_file", "")).name if meta.get("source_file") else ""
160
+
161
+ # Truncate doc to keep L1 compact
162
+ snippet = doc.strip().replace("\n", " ")
163
+ if len(snippet) > 200:
164
+ snippet = snippet[:197] + "..."
165
+
166
+ entry_line = f" - {snippet}"
167
+ if source:
168
+ entry_line += f" ({source})"
169
+
170
+ if total_len + len(entry_line) > self.MAX_CHARS:
171
+ lines.append(" ... (more in L3 search)")
172
+ return "\n".join(lines)
173
+
174
+ lines.append(entry_line)
175
+ total_len += len(entry_line)
176
+
177
+ return "\n".join(lines)
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Layer 2 — On-Demand (wing/room filtered retrieval)
182
+ # ---------------------------------------------------------------------------
183
+
184
+
185
+ class Layer2:
186
+ """
187
+ ~200-500 tokens per retrieval.
188
+ Loaded when a specific topic or wing comes up in conversation.
189
+ Queries ChromaDB with a wing/room filter.
190
+ """
191
+
192
+ def __init__(self, palace_path: str = None):
193
+ cfg = MempalaceConfig()
194
+ self.palace_path = palace_path or cfg.palace_path
195
+
196
+ def retrieve(self, wing: str = None, room: str = None, n_results: int = 10) -> str:
197
+ """Retrieve drawers filtered by wing and/or room."""
198
+ try:
199
+ store = open_store(self.palace_path, create=False)
200
+ col = store
201
+ except Exception:
202
+ return "No palace found."
203
+
204
+ where = {}
205
+ if wing and room:
206
+ where = {"$and": [{"wing": wing}, {"room": room}]}
207
+ elif wing:
208
+ where = {"wing": wing}
209
+ elif room:
210
+ where = {"room": room}
211
+
212
+ kwargs = {"include": ["documents", "metadatas"], "limit": n_results}
213
+ if where:
214
+ kwargs["where"] = where
215
+
216
+ try:
217
+ results = col.get(**kwargs)
218
+ except Exception as e:
219
+ return f"Retrieval error: {e}"
220
+
221
+ docs = results.get("documents", [])
222
+ metas = results.get("metadatas", [])
223
+
224
+ if not docs:
225
+ label = f"wing={wing}" if wing else ""
226
+ if room:
227
+ label += f" room={room}" if label else f"room={room}"
228
+ return f"No drawers found for {label}."
229
+
230
+ lines = [f"## L2 — ON-DEMAND ({len(docs)} drawers)"]
231
+ for doc, meta in zip(docs[:n_results], metas[:n_results]):
232
+ room_name = meta.get("room", "?")
233
+ source = Path(meta.get("source_file", "")).name if meta.get("source_file") else ""
234
+ snippet = doc.strip().replace("\n", " ")
235
+ if len(snippet) > 300:
236
+ snippet = snippet[:297] + "..."
237
+ entry = f" [{room_name}] {snippet}"
238
+ if source:
239
+ entry += f" ({source})"
240
+ lines.append(entry)
241
+
242
+ return "\n".join(lines)
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # Layer 3 — Deep Search (full semantic search via ChromaDB)
247
+ # ---------------------------------------------------------------------------
248
+
249
+
250
+ class Layer3:
251
+ """
252
+ Unlimited depth. Semantic search against the full palace.
253
+ Reuses searcher.py logic against mempalace_drawers.
254
+ """
255
+
256
+ def __init__(self, palace_path: str = None):
257
+ cfg = MempalaceConfig()
258
+ self.palace_path = palace_path or cfg.palace_path
259
+
260
+ def search(self, query: str, wing: str = None, room: str = None, n_results: int = 5) -> str:
261
+ """Semantic search, returns compact result text."""
262
+ try:
263
+ store = open_store(self.palace_path, create=False)
264
+ col = store
265
+ except Exception:
266
+ return "No palace found."
267
+
268
+ where = {}
269
+ if wing and room:
270
+ where = {"$and": [{"wing": wing}, {"room": room}]}
271
+ elif wing:
272
+ where = {"wing": wing}
273
+ elif room:
274
+ where = {"room": room}
275
+
276
+ kwargs = {
277
+ "query_texts": [query],
278
+ "n_results": n_results,
279
+ "include": ["documents", "metadatas", "distances"],
280
+ }
281
+ if where:
282
+ kwargs["where"] = where
283
+
284
+ try:
285
+ results = col.query(**kwargs)
286
+ except Exception as e:
287
+ return f"Search error: {e}"
288
+
289
+ docs = results["documents"][0]
290
+ metas = results["metadatas"][0]
291
+ dists = results["distances"][0]
292
+
293
+ if not docs:
294
+ return "No results found."
295
+
296
+ lines = [f'## L3 — SEARCH RESULTS for "{query}"']
297
+ for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
298
+ similarity = round(1 - dist, 3)
299
+ wing_name = meta.get("wing", "?")
300
+ room_name = meta.get("room", "?")
301
+ source = Path(meta.get("source_file", "")).name if meta.get("source_file") else ""
302
+
303
+ snippet = doc.strip().replace("\n", " ")
304
+ if len(snippet) > 300:
305
+ snippet = snippet[:297] + "..."
306
+
307
+ lines.append(f" [{i}] {wing_name}/{room_name} (sim={similarity})")
308
+ lines.append(f" {snippet}")
309
+ if source:
310
+ lines.append(f" src: {source}")
311
+
312
+ return "\n".join(lines)
313
+
314
+ def search_raw(
315
+ self, query: str, wing: str = None, room: str = None, n_results: int = 5
316
+ ) -> list:
317
+ """Return raw dicts instead of formatted text."""
318
+ try:
319
+ store = open_store(self.palace_path, create=False)
320
+ col = store
321
+ except Exception:
322
+ return []
323
+
324
+ where = {}
325
+ if wing and room:
326
+ where = {"$and": [{"wing": wing}, {"room": room}]}
327
+ elif wing:
328
+ where = {"wing": wing}
329
+ elif room:
330
+ where = {"room": room}
331
+
332
+ kwargs = {
333
+ "query_texts": [query],
334
+ "n_results": n_results,
335
+ "include": ["documents", "metadatas", "distances"],
336
+ }
337
+ if where:
338
+ kwargs["where"] = where
339
+
340
+ try:
341
+ results = col.query(**kwargs)
342
+ except Exception:
343
+ return []
344
+
345
+ hits = []
346
+ for doc, meta, dist in zip(
347
+ results["documents"][0],
348
+ results["metadatas"][0],
349
+ results["distances"][0],
350
+ ):
351
+ hits.append(
352
+ {
353
+ "text": doc,
354
+ "wing": meta.get("wing", "unknown"),
355
+ "room": meta.get("room", "unknown"),
356
+ "source_file": Path(meta.get("source_file", "?")).name,
357
+ "similarity": round(1 - dist, 3),
358
+ "metadata": meta,
359
+ }
360
+ )
361
+ return hits
362
+
363
+
364
+ # ---------------------------------------------------------------------------
365
+ # MemoryStack — unified interface
366
+ # ---------------------------------------------------------------------------
367
+
368
+
369
+ class MemoryStack:
370
+ """
371
+ The full 4-layer stack. One class, one palace, everything works.
372
+
373
+ stack = MemoryStack()
374
+ print(stack.wake_up()) # L0 + L1 (~600-900 tokens)
375
+ print(stack.recall(wing="my_app")) # L2 on-demand
376
+ print(stack.search("pricing change")) # L3 deep search
377
+ """
378
+
379
+ def __init__(self, palace_path: str = None, identity_path: str = None):
380
+ cfg = MempalaceConfig()
381
+ self.palace_path = palace_path or cfg.palace_path
382
+ self.identity_path = identity_path or os.path.expanduser("~/.mempalace/identity.txt")
383
+
384
+ self.l0 = Layer0(self.identity_path)
385
+ self.l1 = Layer1(self.palace_path)
386
+ self.l2 = Layer2(self.palace_path)
387
+ self.l3 = Layer3(self.palace_path)
388
+
389
+ def wake_up(self, wing: str = None) -> str:
390
+ """
391
+ Generate wake-up text: L0 (identity) + L1 (essential story).
392
+ Typically ~600-900 tokens. Inject into system prompt or first message.
393
+
394
+ Args:
395
+ wing: Optional wing filter for L1 (project-specific wake-up).
396
+ """
397
+ parts = []
398
+
399
+ # L0: Identity
400
+ parts.append(self.l0.render())
401
+ parts.append("")
402
+
403
+ # L1: Essential Story
404
+ if wing:
405
+ self.l1.wing = wing
406
+ parts.append(self.l1.generate())
407
+
408
+ return "\n".join(parts)
409
+
410
+ def recall(self, wing: str = None, room: str = None, n_results: int = 10) -> str:
411
+ """On-demand L2 retrieval filtered by wing/room."""
412
+ return self.l2.retrieve(wing=wing, room=room, n_results=n_results)
413
+
414
+ def search(self, query: str, wing: str = None, room: str = None, n_results: int = 5) -> str:
415
+ """Deep L3 semantic search."""
416
+ return self.l3.search(query, wing=wing, room=room, n_results=n_results)
417
+
418
+ def status(self) -> dict:
419
+ """Status of all layers."""
420
+ result = {
421
+ "palace_path": self.palace_path,
422
+ "L0_identity": {
423
+ "path": self.identity_path,
424
+ "exists": os.path.exists(self.identity_path),
425
+ "tokens": self.l0.token_estimate(),
426
+ },
427
+ "L1_essential": {
428
+ "description": "Auto-generated from top palace drawers",
429
+ },
430
+ "L2_on_demand": {
431
+ "description": "Wing/room filtered retrieval",
432
+ },
433
+ "L3_deep_search": {
434
+ "description": "Full semantic search via ChromaDB",
435
+ },
436
+ }
437
+
438
+ # Count drawers
439
+ try:
440
+ store = open_store(self.palace_path, create=False)
441
+ col = store
442
+ count = col.count()
443
+ result["total_drawers"] = count
444
+ except Exception:
445
+ result["total_drawers"] = 0
446
+
447
+ return result
448
+
449
+
450
+ # ---------------------------------------------------------------------------
451
+ # CLI (standalone)
452
+ # ---------------------------------------------------------------------------
453
+
454
+ if __name__ == "__main__":
455
+ import json
456
+
457
+ def usage():
458
+ print("layers.py — 4-Layer Memory Stack")
459
+ print()
460
+ print("Usage:")
461
+ print(" python layers.py wake-up Show L0 + L1")
462
+ print(" python layers.py wake-up --wing=NAME Wake-up for a specific project")
463
+ print(" python layers.py recall --wing=NAME On-demand L2 retrieval")
464
+ print(" python layers.py search <query> Deep L3 search")
465
+ print(" python layers.py status Show layer status")
466
+ sys.exit(0)
467
+
468
+ if len(sys.argv) < 2:
469
+ usage()
470
+
471
+ cmd = sys.argv[1]
472
+
473
+ # Parse flags
474
+ flags = {}
475
+ positional = []
476
+ for arg in sys.argv[2:]:
477
+ if arg.startswith("--") and "=" in arg:
478
+ key, val = arg.split("=", 1)
479
+ flags[key.lstrip("-")] = val
480
+ elif not arg.startswith("--"):
481
+ positional.append(arg)
482
+
483
+ palace_path = flags.get("palace")
484
+ stack = MemoryStack(palace_path=palace_path)
485
+
486
+ if cmd in ("wake-up", "wakeup"):
487
+ wing = flags.get("wing")
488
+ text = stack.wake_up(wing=wing)
489
+ tokens = len(text) // 4
490
+ print(f"Wake-up text (~{tokens} tokens):")
491
+ print("=" * 50)
492
+ print(text)
493
+
494
+ elif cmd == "recall":
495
+ wing = flags.get("wing")
496
+ room = flags.get("room")
497
+ text = stack.recall(wing=wing, room=room)
498
+ print(text)
499
+
500
+ elif cmd == "search":
501
+ query = " ".join(positional) if positional else ""
502
+ if not query:
503
+ print("Usage: python layers.py search <query>")
504
+ sys.exit(1)
505
+ wing = flags.get("wing")
506
+ room = flags.get("room")
507
+ text = stack.search(query, wing=wing, room=room)
508
+ print(text)
509
+
510
+ elif cmd == "status":
511
+ s = stack.status()
512
+ print(json.dumps(s, indent=2))
513
+
514
+ else:
515
+ usage()