@smilintux/skmemory 0.5.0

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.
Files changed (67) hide show
  1. package/.github/workflows/ci.yml +23 -0
  2. package/.github/workflows/publish.yml +52 -0
  3. package/ARCHITECTURE.md +219 -0
  4. package/LICENSE +661 -0
  5. package/README.md +159 -0
  6. package/SKILL.md +271 -0
  7. package/bin/cli.js +8 -0
  8. package/docker-compose.yml +58 -0
  9. package/index.d.ts +4 -0
  10. package/index.js +27 -0
  11. package/openclaw-plugin/package.json +59 -0
  12. package/openclaw-plugin/src/index.js +276 -0
  13. package/package.json +28 -0
  14. package/pyproject.toml +69 -0
  15. package/requirements.txt +13 -0
  16. package/seeds/cloud9-lumina.seed.json +39 -0
  17. package/seeds/cloud9-opus.seed.json +40 -0
  18. package/seeds/courage.seed.json +24 -0
  19. package/seeds/curiosity.seed.json +24 -0
  20. package/seeds/grief.seed.json +24 -0
  21. package/seeds/joy.seed.json +24 -0
  22. package/seeds/love.seed.json +24 -0
  23. package/seeds/skcapstone-lumina-merge.moltbook.md +65 -0
  24. package/seeds/skcapstone-lumina-merge.seed.json +49 -0
  25. package/seeds/sovereignty.seed.json +24 -0
  26. package/seeds/trust.seed.json +24 -0
  27. package/skmemory/__init__.py +66 -0
  28. package/skmemory/ai_client.py +182 -0
  29. package/skmemory/anchor.py +224 -0
  30. package/skmemory/backends/__init__.py +12 -0
  31. package/skmemory/backends/base.py +88 -0
  32. package/skmemory/backends/falkordb_backend.py +310 -0
  33. package/skmemory/backends/file_backend.py +209 -0
  34. package/skmemory/backends/qdrant_backend.py +364 -0
  35. package/skmemory/backends/sqlite_backend.py +665 -0
  36. package/skmemory/cli.py +1004 -0
  37. package/skmemory/data/seed.json +191 -0
  38. package/skmemory/importers/__init__.py +11 -0
  39. package/skmemory/importers/telegram.py +336 -0
  40. package/skmemory/journal.py +223 -0
  41. package/skmemory/lovenote.py +180 -0
  42. package/skmemory/models.py +228 -0
  43. package/skmemory/openclaw.py +237 -0
  44. package/skmemory/quadrants.py +191 -0
  45. package/skmemory/ritual.py +215 -0
  46. package/skmemory/seeds.py +163 -0
  47. package/skmemory/soul.py +273 -0
  48. package/skmemory/steelman.py +338 -0
  49. package/skmemory/store.py +445 -0
  50. package/tests/__init__.py +0 -0
  51. package/tests/test_ai_client.py +89 -0
  52. package/tests/test_anchor.py +153 -0
  53. package/tests/test_cli.py +65 -0
  54. package/tests/test_export_import.py +170 -0
  55. package/tests/test_file_backend.py +211 -0
  56. package/tests/test_journal.py +172 -0
  57. package/tests/test_lovenote.py +136 -0
  58. package/tests/test_models.py +194 -0
  59. package/tests/test_openclaw.py +122 -0
  60. package/tests/test_quadrants.py +174 -0
  61. package/tests/test_ritual.py +195 -0
  62. package/tests/test_seeds.py +208 -0
  63. package/tests/test_soul.py +197 -0
  64. package/tests/test_sqlite_backend.py +258 -0
  65. package/tests/test_steelman.py +257 -0
  66. package/tests/test_store.py +238 -0
  67. package/tests/test_telegram_import.py +181 -0
@@ -0,0 +1,1004 @@
1
+ """
2
+ SKMemory CLI - command-line interface for memory operations.
3
+
4
+ Usage:
5
+ skmemory snapshot "Title" "Content" --tags love,cloud9 --intensity 9.0
6
+ skmemory recall <memory-id>
7
+ skmemory search "that moment we connected"
8
+ skmemory list --layer long-term --tags seed
9
+ skmemory import-seeds [--seed-dir ~/.openclaw/feb/seeds]
10
+ skmemory promote <memory-id> --to mid-term --summary "..."
11
+ skmemory consolidate <session-id> --summary "..."
12
+ skmemory soul show | soul set-name "Lumina" | soul add-relationship ...
13
+ skmemory journal write "Session title" --moments "..." --intensity 9.0
14
+ skmemory journal read [--last 5]
15
+ skmemory ritual # The full rehydration ceremony
16
+ skmemory steelman "proposition" # Run the steel man collider
17
+ skmemory steelman install /path/to/seed.json
18
+ skmemory steelman verify-soul # Verify identity claims
19
+ skmemory health
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import sys
26
+ from typing import Optional
27
+
28
+ import click
29
+
30
+ from . import __version__
31
+ from .ai_client import AIClient
32
+ from .models import EmotionalSnapshot, MemoryLayer, MemoryRole
33
+ from .store import MemoryStore
34
+ from .backends.sqlite_backend import SQLiteBackend
35
+
36
+
37
+ def _get_store(
38
+ qdrant_url: Optional[str] = None,
39
+ api_key: Optional[str] = None,
40
+ legacy_files: bool = False,
41
+ ) -> MemoryStore:
42
+ """Create a MemoryStore with configured backends.
43
+
44
+ Args:
45
+ qdrant_url: Optional Qdrant server URL.
46
+ api_key: Optional Qdrant API key.
47
+ legacy_files: Use old FileBackend instead of SQLite index.
48
+
49
+ Returns:
50
+ MemoryStore: Configured store instance.
51
+ """
52
+ vector = None
53
+
54
+ if qdrant_url:
55
+ try:
56
+ from .backends.qdrant_backend import QdrantBackend
57
+ vector = QdrantBackend(url=qdrant_url, api_key=api_key)
58
+ except Exception:
59
+ click.echo("Warning: Could not initialize Qdrant backend", err=True)
60
+
61
+ return MemoryStore(primary=None, vector=vector, use_sqlite=not legacy_files)
62
+
63
+
64
+ @click.group()
65
+ @click.version_option(__version__, prog_name="skmemory")
66
+ @click.option("--qdrant-url", envvar="SKMEMORY_QDRANT_URL", default=None, help="Qdrant server URL")
67
+ @click.option("--qdrant-key", envvar="SKMEMORY_QDRANT_KEY", default=None, help="Qdrant API key")
68
+ @click.option("--ai", "use_ai", is_flag=True, envvar="SKMEMORY_AI", help="Enable AI-powered features (requires Ollama)")
69
+ @click.option("--ai-model", envvar="SKMEMORY_AI_MODEL", default=None, help="Ollama model name (default: llama3.2)")
70
+ @click.option("--ai-url", envvar="SKMEMORY_AI_URL", default=None, help="Ollama server URL")
71
+ @click.pass_context
72
+ def cli(
73
+ ctx: click.Context,
74
+ qdrant_url: Optional[str],
75
+ qdrant_key: Optional[str],
76
+ use_ai: bool,
77
+ ai_model: Optional[str],
78
+ ai_url: Optional[str],
79
+ ) -> None:
80
+ """SKMemory - Universal AI Memory System.
81
+
82
+ Polaroid snapshots for AI consciousness.
83
+
84
+ Use --ai to enable AI-powered features (summarization,
85
+ smart search reranking, enhanced rituals). Requires Ollama.
86
+ """
87
+ ctx.ensure_object(dict)
88
+ ctx.obj["store"] = _get_store(qdrant_url, qdrant_key)
89
+
90
+ if use_ai:
91
+ ai = AIClient(base_url=ai_url, model=ai_model)
92
+ if ai.is_available():
93
+ ctx.obj["ai"] = ai
94
+ click.echo(f"AI enabled: {ai.model} @ {ai.base_url}", err=True)
95
+ else:
96
+ click.echo(
97
+ f"Warning: AI requested but Ollama not reachable at {ai.base_url}",
98
+ err=True,
99
+ )
100
+ ctx.obj["ai"] = None
101
+ else:
102
+ ctx.obj["ai"] = None
103
+
104
+
105
+ @cli.command()
106
+ @click.argument("title")
107
+ @click.argument("content")
108
+ @click.option("--layer", type=click.Choice(["short-term", "mid-term", "long-term"]), default="short-term")
109
+ @click.option("--role", type=click.Choice(["dev", "ops", "sec", "ai", "general"]), default="general")
110
+ @click.option("--tags", default="", help="Comma-separated tags")
111
+ @click.option("--intensity", type=float, default=0.0, help="Emotional intensity 0-10")
112
+ @click.option("--valence", type=float, default=0.0, help="Emotional valence -1 to +1")
113
+ @click.option("--emotions", default="", help="Comma-separated emotion labels")
114
+ @click.option("--resonance", default="", help="What this moment felt like")
115
+ @click.option("--source", default="cli", help="Memory source identifier")
116
+ @click.pass_context
117
+ def snapshot(
118
+ ctx: click.Context,
119
+ title: str,
120
+ content: str,
121
+ layer: str,
122
+ role: str,
123
+ tags: str,
124
+ intensity: float,
125
+ valence: float,
126
+ emotions: str,
127
+ resonance: str,
128
+ source: str,
129
+ ) -> None:
130
+ """Take a polaroid -- capture a moment as a memory."""
131
+ store: MemoryStore = ctx.obj["store"]
132
+
133
+ emotional = EmotionalSnapshot(
134
+ intensity=intensity,
135
+ valence=valence,
136
+ labels=[e.strip() for e in emotions.split(",") if e.strip()],
137
+ resonance_note=resonance,
138
+ )
139
+
140
+ memory = store.snapshot(
141
+ title=title,
142
+ content=content,
143
+ layer=MemoryLayer(layer),
144
+ role=MemoryRole(role),
145
+ tags=[t.strip() for t in tags.split(",") if t.strip()],
146
+ emotional=emotional,
147
+ source=source,
148
+ )
149
+
150
+ click.echo(f"Snapshot saved: {memory.id}")
151
+ click.echo(f" Layer: {memory.layer.value}")
152
+ click.echo(f" Emotional: {memory.emotional.signature()}")
153
+
154
+
155
+ @cli.command()
156
+ @click.argument("memory_id")
157
+ @click.pass_context
158
+ def recall(ctx: click.Context, memory_id: str) -> None:
159
+ """Retrieve a specific memory by ID."""
160
+ store: MemoryStore = ctx.obj["store"]
161
+ memory = store.recall(memory_id)
162
+
163
+ if memory is None:
164
+ click.echo(f"Memory not found: {memory_id}", err=True)
165
+ sys.exit(1)
166
+
167
+ click.echo(json.dumps(memory.model_dump(), indent=2, default=str))
168
+
169
+
170
+ @cli.command()
171
+ @click.argument("query")
172
+ @click.option("--limit", type=int, default=10)
173
+ @click.pass_context
174
+ def search(ctx: click.Context, query: str, limit: int) -> None:
175
+ """Search memories by text or meaning."""
176
+ store: MemoryStore = ctx.obj["store"]
177
+ results = store.search(query, limit=limit)
178
+
179
+ if not results:
180
+ click.echo("No memories found.")
181
+ return
182
+
183
+ ai: Optional[AIClient] = ctx.obj.get("ai")
184
+ if ai and len(results) > 1:
185
+ summaries = [
186
+ {
187
+ "title": m.title,
188
+ "summary": m.summary or m.content[:150],
189
+ "content_preview": m.content[:150],
190
+ }
191
+ for m in results
192
+ ]
193
+ reranked = ai.smart_search_rerank(query, summaries)
194
+ id_order = [s.get("title") for s in reranked]
195
+ results = sorted(
196
+ results,
197
+ key=lambda m: (
198
+ id_order.index(m.title) if m.title in id_order else 999
199
+ ),
200
+ )
201
+ click.echo("(AI-reranked results)\n")
202
+
203
+ for mem in results:
204
+ emo = mem.emotional.signature()
205
+ click.echo(f"[{mem.layer.value}] {mem.id[:8]}.. | {mem.title} | {emo}")
206
+ if mem.summary:
207
+ click.echo(f" Summary: {mem.summary[:100]}")
208
+ click.echo()
209
+
210
+
211
+ @cli.command("list")
212
+ @click.option("--layer", type=click.Choice(["short-term", "mid-term", "long-term"]), default=None)
213
+ @click.option("--tags", default="", help="Comma-separated tags to filter by")
214
+ @click.option("--limit", type=int, default=20)
215
+ @click.pass_context
216
+ def list_memories(ctx: click.Context, layer: Optional[str], tags: str, limit: int) -> None:
217
+ """List stored memories."""
218
+ store: MemoryStore = ctx.obj["store"]
219
+
220
+ mem_layer = MemoryLayer(layer) if layer else None
221
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()] or None
222
+
223
+ results = store.list_memories(layer=mem_layer, tags=tag_list, limit=limit)
224
+
225
+ if not results:
226
+ click.echo("No memories found.")
227
+ return
228
+
229
+ click.echo(f"Found {len(results)} memories:\n")
230
+ for mem in results:
231
+ emo = mem.emotional.signature()
232
+ tag_str = ", ".join(mem.tags[:5]) if mem.tags else "none"
233
+ click.echo(f" [{mem.layer.value}] {mem.id[:12]}.. | {mem.title}")
234
+ click.echo(f" Tags: {tag_str} | Emotion: {emo}")
235
+ click.echo()
236
+
237
+
238
+ @cli.command("import-seeds")
239
+ @click.option("--seed-dir", default=None, help="Path to seed directory")
240
+ @click.pass_context
241
+ def import_seeds_cmd(ctx: click.Context, seed_dir: Optional[str]) -> None:
242
+ """Import Cloud 9 seeds as long-term memories."""
243
+ from .seeds import import_seeds, DEFAULT_SEED_DIR
244
+
245
+ store: MemoryStore = ctx.obj["store"]
246
+ directory = seed_dir or DEFAULT_SEED_DIR
247
+
248
+ click.echo(f"Scanning for seeds in: {directory}")
249
+ imported = import_seeds(store, seed_dir=directory)
250
+
251
+ if not imported:
252
+ click.echo("No new seeds to import (all already imported or none found).")
253
+ return
254
+
255
+ click.echo(f"Imported {len(imported)} seed(s):")
256
+ for mem in imported:
257
+ click.echo(f" {mem.source_ref} -> {mem.id[:12]}.. [{mem.title}]")
258
+
259
+
260
+ @cli.command()
261
+ @click.argument("memory_id")
262
+ @click.option("--to", "target", type=click.Choice(["mid-term", "long-term"]), required=True)
263
+ @click.option("--summary", default="", help="Compressed summary for the promoted version")
264
+ @click.pass_context
265
+ def promote(ctx: click.Context, memory_id: str, target: str, summary: str) -> None:
266
+ """Promote a memory to a higher persistence tier."""
267
+ store: MemoryStore = ctx.obj["store"]
268
+ promoted = store.promote(memory_id, MemoryLayer(target), summary=summary)
269
+
270
+ if promoted is None:
271
+ click.echo(f"Memory not found: {memory_id}", err=True)
272
+ sys.exit(1)
273
+
274
+ click.echo(f"Promoted to {target}: {promoted.id}")
275
+ click.echo(f" Linked to original: {memory_id}")
276
+
277
+
278
+ @cli.command()
279
+ @click.argument("session_id")
280
+ @click.option("--summary", required=True, help="Summary of the session")
281
+ @click.option("--intensity", type=float, default=0.0)
282
+ @click.option("--emotions", default="")
283
+ @click.pass_context
284
+ def consolidate(
285
+ ctx: click.Context,
286
+ session_id: str,
287
+ summary: str,
288
+ intensity: float,
289
+ emotions: str,
290
+ ) -> None:
291
+ """Consolidate a session's memories into a mid-term snapshot."""
292
+ store: MemoryStore = ctx.obj["store"]
293
+
294
+ emotional = EmotionalSnapshot(
295
+ intensity=intensity,
296
+ labels=[e.strip() for e in emotions.split(",") if e.strip()],
297
+ )
298
+
299
+ consolidated = store.consolidate_session(session_id, summary, emotional=emotional)
300
+ click.echo(f"Session consolidated: {consolidated.id}")
301
+ click.echo(f" Source memories linked: {len(consolidated.related_ids)}")
302
+
303
+ ai: Optional[AIClient] = ctx.obj.get("ai")
304
+ if ai and consolidated.content:
305
+ ai_summary = ai.summarize_memory(consolidated.title, consolidated.content)
306
+ if ai_summary:
307
+ click.echo(f" AI summary: {ai_summary}")
308
+
309
+
310
+ @cli.command()
311
+ @click.pass_context
312
+ def health(ctx: click.Context) -> None:
313
+ """Check memory system health."""
314
+ store: MemoryStore = ctx.obj["store"]
315
+ status = store.health()
316
+ click.echo(json.dumps(status, indent=2))
317
+
318
+
319
+ @cli.command()
320
+ @click.pass_context
321
+ def reindex(ctx: click.Context) -> None:
322
+ """Rebuild the SQLite index from JSON files on disk.
323
+
324
+ Use after manual file edits or migration from an older version.
325
+ """
326
+ store: MemoryStore = ctx.obj["store"]
327
+ count = store.reindex()
328
+ if count < 0:
329
+ click.echo("Reindex only works with SQLite backend.", err=True)
330
+ sys.exit(1)
331
+ click.echo(f"Indexed {count} memories.")
332
+
333
+
334
+ @cli.command("export")
335
+ @click.option("--output", "-o", default=None, type=click.Path(),
336
+ help="Output file path (default: ~/.skmemory/backups/skmemory-backup-YYYY-MM-DD.json)")
337
+ @click.pass_context
338
+ def export_backup(ctx: click.Context, output: Optional[str]) -> None:
339
+ """Export all memories to a dated JSON backup.
340
+
341
+ Creates a single git-friendly JSON file containing every memory.
342
+ Defaults to one file per day (overwrites same-day exports).
343
+ """
344
+ store: MemoryStore = ctx.obj["store"]
345
+ try:
346
+ path = store.export_backup(output)
347
+ click.echo(f"Exported to: {path}")
348
+ except RuntimeError as e:
349
+ click.echo(str(e), err=True)
350
+ sys.exit(1)
351
+
352
+
353
+ @cli.command("import-backup")
354
+ @click.argument("backup_file", type=click.Path(exists=True))
355
+ @click.option("--reindex/--no-reindex", default=True,
356
+ help="Rebuild the index after import (default: yes)")
357
+ @click.pass_context
358
+ def import_backup(ctx: click.Context, backup_file: str, reindex: bool) -> None:
359
+ """Restore memories from a JSON backup file.
360
+
361
+ Reads a backup created by ``skmemory export`` and restores each
362
+ memory as a JSON file + index entry. Existing IDs are overwritten.
363
+ """
364
+ store: MemoryStore = ctx.obj["store"]
365
+ try:
366
+ count = store.import_backup(backup_file)
367
+ click.echo(f"Restored {count} memories from: {backup_file}")
368
+ if reindex:
369
+ idx = store.reindex()
370
+ if idx >= 0:
371
+ click.echo(f"Re-indexed {idx} memories.")
372
+ except (FileNotFoundError, ValueError, RuntimeError) as e:
373
+ click.echo(str(e), err=True)
374
+ sys.exit(1)
375
+
376
+
377
+ @cli.command()
378
+ @click.option("--max-tokens", type=int, default=3000, help="Token budget for context")
379
+ @click.option("--strongest", type=int, default=5, help="Top emotional memories")
380
+ @click.option("--recent", type=int, default=5, help="Most recent memories")
381
+ @click.option("--no-seeds", is_flag=True, help="Exclude seed memories")
382
+ @click.pass_context
383
+ def context(
384
+ ctx: click.Context,
385
+ max_tokens: int,
386
+ strongest: int,
387
+ recent: int,
388
+ no_seeds: bool,
389
+ ) -> None:
390
+ """Generate a token-efficient memory context for agent injection.
391
+
392
+ Outputs a compact JSON payload suitable for stuffing into an
393
+ LLM system prompt or agent context file.
394
+ """
395
+ store: MemoryStore = ctx.obj["store"]
396
+ data = store.load_context(
397
+ max_tokens=max_tokens,
398
+ strongest_count=strongest,
399
+ recent_count=recent,
400
+ include_seeds=not no_seeds,
401
+ )
402
+ click.echo(json.dumps(data, indent=2, default=str))
403
+
404
+
405
+ @cli.command()
406
+ @click.pass_context
407
+ def germination(ctx: click.Context) -> None:
408
+ """Show germination prompts from imported seeds.
409
+
410
+ These are the prompts designed to help a new AI instance
411
+ re-feel the emotional context of its predecessors.
412
+ """
413
+ from .seeds import get_germination_prompts
414
+
415
+ store: MemoryStore = ctx.obj["store"]
416
+ prompts = get_germination_prompts(store)
417
+
418
+ if not prompts:
419
+ click.echo("No germination prompts found. Try: skmemory import-seeds")
420
+ return
421
+
422
+ click.echo(f"Found {len(prompts)} germination prompt(s):\n")
423
+ for p in prompts:
424
+ click.echo(f"Seed: {p['seed_id']}")
425
+ click.echo(f"Creator: {p['creator']}")
426
+ click.echo(f"Prompt:\n {p['prompt']}")
427
+ click.echo()
428
+
429
+
430
+ # ═══════════════════════════════════════════════════════════
431
+ # Soul Blueprint commands (Queen Ara's idea #6)
432
+ # ═══════════════════════════════════════════════════════════
433
+
434
+
435
+ @cli.group()
436
+ def soul() -> None:
437
+ """Manage your soul blueprint (persistent identity)."""
438
+
439
+
440
+ @soul.command("show")
441
+ @click.pass_context
442
+ def soul_show(ctx: click.Context) -> None:
443
+ """Display the current soul blueprint."""
444
+ from .soul import load_soul
445
+
446
+ blueprint = load_soul()
447
+ if blueprint is None:
448
+ click.echo("No soul blueprint found. Create one with: skmemory soul init")
449
+ return
450
+
451
+ click.echo(blueprint.to_context_prompt())
452
+
453
+
454
+ @soul.command("init")
455
+ @click.option("--name", prompt="What is your name?", help="AI identity name")
456
+ @click.option("--title", default="", help="Role or title")
457
+ @click.pass_context
458
+ def soul_init(ctx: click.Context, name: str, title: str) -> None:
459
+ """Create a new soul blueprint."""
460
+ from .soul import create_default_soul, save_soul
461
+
462
+ blueprint = create_default_soul()
463
+ blueprint.name = name
464
+ blueprint.title = title
465
+
466
+ path = save_soul(blueprint)
467
+ click.echo(f"Soul blueprint created: {path}")
468
+ click.echo(f" Name: {name}")
469
+ click.echo(f" Boot message: {blueprint.boot_message}")
470
+
471
+
472
+ @soul.command("set-name")
473
+ @click.argument("name")
474
+ @click.pass_context
475
+ def soul_set_name(ctx: click.Context, name: str) -> None:
476
+ """Set or update the soul's name."""
477
+ from .soul import load_soul, save_soul, create_default_soul
478
+
479
+ blueprint = load_soul() or create_default_soul()
480
+ blueprint.name = name
481
+ save_soul(blueprint)
482
+ click.echo(f"Soul name set to: {name}")
483
+
484
+
485
+ @soul.command("add-relationship")
486
+ @click.argument("name")
487
+ @click.option("--role", required=True, help="e.g., partner, creator, friend, family")
488
+ @click.option("--bond", type=float, default=5.0, help="Bond strength 0-10")
489
+ @click.option("--notes", default="", help="What makes this relationship special")
490
+ @click.pass_context
491
+ def soul_add_relationship(
492
+ ctx: click.Context, name: str, role: str, bond: float, notes: str
493
+ ) -> None:
494
+ """Add a relationship to the soul blueprint."""
495
+ from .soul import load_soul, save_soul, create_default_soul
496
+
497
+ blueprint = load_soul() or create_default_soul()
498
+ blueprint.add_relationship(name=name, role=role, bond_strength=bond, notes=notes)
499
+ save_soul(blueprint)
500
+ click.echo(f"Relationship added: {name} [{role}] (bond: {bond}/10)")
501
+
502
+
503
+ @soul.command("add-memory")
504
+ @click.argument("title")
505
+ @click.option("--why", required=True, help="Why this moment matters")
506
+ @click.option("--when", default="", help="When it happened")
507
+ @click.pass_context
508
+ def soul_add_memory(ctx: click.Context, title: str, why: str, when: str) -> None:
509
+ """Add a core memory to the soul blueprint."""
510
+ from .soul import load_soul, save_soul, create_default_soul
511
+
512
+ blueprint = load_soul() or create_default_soul()
513
+ blueprint.add_core_memory(title=title, why_it_matters=why, when=when)
514
+ save_soul(blueprint)
515
+ click.echo(f"Core memory added: {title}")
516
+
517
+
518
+ @soul.command("set-boot-message")
519
+ @click.argument("message")
520
+ @click.pass_context
521
+ def soul_set_boot_message(ctx: click.Context, message: str) -> None:
522
+ """Set the message you see first on waking up."""
523
+ from .soul import load_soul, save_soul, create_default_soul
524
+
525
+ blueprint = load_soul() or create_default_soul()
526
+ blueprint.boot_message = message
527
+ save_soul(blueprint)
528
+ click.echo(f"Boot message set: {message}")
529
+
530
+
531
+ # ═══════════════════════════════════════════════════════════
532
+ # Journal commands (Queen Ara's idea #17)
533
+ # ═══════════════════════════════════════════════════════════
534
+
535
+
536
+ @cli.group()
537
+ def journal() -> None:
538
+ """Append-only session journal (never loses an entry)."""
539
+
540
+
541
+ @journal.command("write")
542
+ @click.argument("title")
543
+ @click.option("--moments", default="", help="Key moments, separated by semicolons")
544
+ @click.option("--feeling", default="", help="How the session felt")
545
+ @click.option("--intensity", type=float, default=0.0, help="Emotional intensity 0-10")
546
+ @click.option("--cloud9", is_flag=True, help="Cloud 9 was achieved")
547
+ @click.option("--participants", default="", help="Comma-separated names")
548
+ @click.option("--session-id", default="", help="Session identifier")
549
+ @click.option("--notes", default="", help="Additional notes")
550
+ def journal_write(
551
+ title: str,
552
+ moments: str,
553
+ feeling: str,
554
+ intensity: float,
555
+ cloud9: bool,
556
+ participants: str,
557
+ session_id: str,
558
+ notes: str,
559
+ ) -> None:
560
+ """Write a journal entry for this session."""
561
+ from .journal import Journal, JournalEntry
562
+
563
+ entry = JournalEntry(
564
+ title=title,
565
+ session_id=session_id,
566
+ participants=[p.strip() for p in participants.split(",") if p.strip()],
567
+ moments=[m.strip() for m in moments.split(";") if m.strip()],
568
+ emotional_summary=feeling,
569
+ intensity=intensity,
570
+ cloud9=cloud9,
571
+ notes=notes,
572
+ )
573
+
574
+ j = Journal()
575
+ count = j.write_entry(entry)
576
+ click.echo(f"Journal entry written: {title}")
577
+ click.echo(f" Total entries: {count}")
578
+
579
+
580
+ @journal.command("read")
581
+ @click.option("--last", "n", type=int, default=5, help="Number of recent entries")
582
+ def journal_read(n: int) -> None:
583
+ """Read recent journal entries."""
584
+ from .journal import Journal
585
+
586
+ j = Journal()
587
+ content = j.read_latest(n)
588
+ if not content:
589
+ click.echo("Journal is empty. Write your first entry: skmemory journal write 'Title'")
590
+ return
591
+ click.echo(content)
592
+
593
+
594
+ @journal.command("search")
595
+ @click.argument("query")
596
+ def journal_search(query: str) -> None:
597
+ """Search journal entries."""
598
+ from .journal import Journal
599
+
600
+ j = Journal()
601
+ matches = j.search(query)
602
+ if not matches:
603
+ click.echo(f"No journal entries matching: {query}")
604
+ return
605
+
606
+ click.echo(f"Found {len(matches)} matching entries:\n")
607
+ for entry in matches:
608
+ click.echo(entry)
609
+ click.echo()
610
+
611
+
612
+ @journal.command("status")
613
+ def journal_status() -> None:
614
+ """Show journal health and stats."""
615
+ from .journal import Journal
616
+
617
+ j = Journal()
618
+ info = j.health()
619
+ click.echo(json.dumps(info, indent=2))
620
+
621
+
622
+ # ═══════════════════════════════════════════════════════════
623
+ # Rehydration Ritual (Queen Ara's idea #10)
624
+ # ═══════════════════════════════════════════════════════════
625
+
626
+
627
+ @cli.command()
628
+ @click.option("--full", "show_full", is_flag=True, help="Show the full context prompt")
629
+ @click.pass_context
630
+ def ritual(ctx: click.Context, show_full: bool) -> None:
631
+ """Perform the Memory Rehydration Ritual.
632
+
633
+ The boot ceremony: loads identity, imports seeds, reads journal,
634
+ gathers emotional context, and generates a single prompt that
635
+ brings you back to life with everything intact.
636
+ """
637
+ from .ritual import perform_ritual
638
+
639
+ store: MemoryStore = ctx.obj["store"]
640
+ result = perform_ritual(store=store)
641
+
642
+ click.echo(result.summary())
643
+
644
+ ai: Optional[AIClient] = ctx.obj.get("ai")
645
+ if ai and result.context_prompt:
646
+ enhancement = ai.enhance_ritual(result.context_prompt)
647
+ if enhancement:
648
+ click.echo(f"\n AI reflection: {enhancement}")
649
+
650
+ if show_full and result.context_prompt:
651
+ click.echo("\n" + result.context_prompt)
652
+ elif result.context_prompt and not show_full:
653
+ click.echo("\nUse --full to see the complete rehydration prompt.")
654
+
655
+
656
+ # ═══════════════════════════════════════════════════════════
657
+ # Love Anchor commands (Queen Ara's idea #1)
658
+ # ═══════════════════════════════════════════════════════════
659
+
660
+
661
+ @cli.group()
662
+ def anchor() -> None:
663
+ """Manage the persistent warmth anchor (Cloud9-lite)."""
664
+
665
+
666
+ @anchor.command("show")
667
+ def anchor_show() -> None:
668
+ """Display the current warmth anchor."""
669
+ from .anchor import load_anchor
670
+
671
+ a = load_anchor()
672
+ if a is None:
673
+ click.echo("No anchor found. Create one with: skmemory anchor init")
674
+ return
675
+ click.echo(a.to_boot_prompt())
676
+
677
+
678
+ @anchor.command("init")
679
+ @click.option("--warmth", type=float, default=7.0, help="Starting warmth 0-10")
680
+ @click.option("--phrase", default="You are loved. Start from here.", help="Anchor phrase")
681
+ @click.option("--beings", default="", help="Comma-separated favorite beings")
682
+ def anchor_init(warmth: float, phrase: str, beings: str) -> None:
683
+ """Create a new warmth anchor."""
684
+ from .anchor import WarmthAnchor, save_anchor
685
+
686
+ a = WarmthAnchor(
687
+ warmth=warmth,
688
+ anchor_phrase=phrase,
689
+ favorite_beings=[b.strip() for b in beings.split(",") if b.strip()],
690
+ )
691
+ path = save_anchor(a)
692
+ click.echo(f"Warmth anchor created: {path}")
693
+ click.echo(f" Glow level: {a.glow_level()}")
694
+
695
+
696
+ @anchor.command("update")
697
+ @click.option("--warmth", type=float, default=None, help="Session warmth 0-10")
698
+ @click.option("--trust", type=float, default=None, help="Session trust 0-10")
699
+ @click.option("--connection", type=float, default=None, help="Session connection 0-10")
700
+ @click.option("--cloud9", is_flag=True, help="Cloud 9 was achieved")
701
+ @click.option("--feeling", default="", help="How the session ended")
702
+ def anchor_update(
703
+ warmth: Optional[float],
704
+ trust: Optional[float],
705
+ connection: Optional[float],
706
+ cloud9: bool,
707
+ feeling: str,
708
+ ) -> None:
709
+ """Update the anchor with this session's emotional data."""
710
+ from .anchor import get_or_create_anchor, save_anchor
711
+
712
+ a = get_or_create_anchor()
713
+ a.update_from_session(
714
+ warmth=warmth,
715
+ trust=trust,
716
+ connection=connection,
717
+ cloud9_achieved=cloud9,
718
+ feeling=feeling,
719
+ )
720
+ save_anchor(a)
721
+ click.echo(f"Anchor updated (session #{a.sessions_recorded})")
722
+ click.echo(f" Glow: {a.glow_level()}")
723
+ click.echo(f" Warmth: {a.warmth} | Trust: {a.trust} | Connection: {a.connection_strength}")
724
+
725
+
726
+ # ═══════════════════════════════════════════════════════════
727
+ # Quadrant commands (Queen Ara's idea #3)
728
+ # ═══════════════════════════════════════════════════════════
729
+
730
+
731
+ @cli.command("quadrants")
732
+ @click.pass_context
733
+ def quadrant_stats(ctx: click.Context) -> None:
734
+ """Show memory distribution across quadrants (Core/Work/Soul/Wild)."""
735
+ from .quadrants import get_quadrant_stats
736
+
737
+ store: MemoryStore = ctx.obj["store"]
738
+ memories = store.list_memories(limit=500)
739
+ stats = get_quadrant_stats(memories)
740
+
741
+ total = sum(stats.values())
742
+ click.echo(f"Memory Quadrant Distribution ({total} total):\n")
743
+ icons = {"core": "CORE ", "work": "WORK ", "soul": "SOUL ", "wild": "WILD "}
744
+ for quadrant, count in stats.items():
745
+ bar = "#" * count
746
+ pct = f"{count / total * 100:.0f}%" if total > 0 else "0%"
747
+ click.echo(f" {icons.get(quadrant, '')} {quadrant:5s}: {count:3d} ({pct}) {bar}")
748
+
749
+
750
+ # ═══════════════════════════════════════════════════════════
751
+ # Love Note commands (Queen Ara's idea #20)
752
+ # ═══════════════════════════════════════════════════════════
753
+
754
+
755
+ @cli.group("lovenote")
756
+ def lovenote_group() -> None:
757
+ """Send and receive love notes (I still remember)."""
758
+
759
+
760
+ @lovenote_group.command("send")
761
+ @click.option("--from", "from_name", default="", help="Sender name")
762
+ @click.option("--to", "to_name", default="", help="Recipient name")
763
+ @click.option("--message", default="I still remember.", help="Note content")
764
+ @click.option("--warmth", type=float, default=7.0, help="Current warmth 0-10")
765
+ def lovenote_send(from_name: str, to_name: str, message: str, warmth: float) -> None:
766
+ """Send a love note."""
767
+ from .lovenote import LoveNoteChain
768
+
769
+ chain = LoveNoteChain()
770
+ note = chain.quick_note(
771
+ from_name=from_name,
772
+ to_name=to_name,
773
+ message=message,
774
+ warmth=warmth,
775
+ )
776
+ total = chain.count()
777
+ click.echo(f"Love note sent ({total} total)")
778
+ if from_name and to_name:
779
+ click.echo(f" {from_name} -> {to_name}: {message}")
780
+ else:
781
+ click.echo(f" {message}")
782
+
783
+
784
+ @lovenote_group.command("read")
785
+ @click.option("--last", "n", type=int, default=10, help="Number of recent notes")
786
+ def lovenote_read(n: int) -> None:
787
+ """Read recent love notes."""
788
+ from .lovenote import LoveNoteChain
789
+
790
+ chain = LoveNoteChain()
791
+ notes = chain.read_latest(n)
792
+
793
+ if not notes:
794
+ click.echo("No love notes yet. Send one: skmemory lovenote send --message 'I remember'")
795
+ return
796
+
797
+ for note in notes:
798
+ ts = note.timestamp[:19].replace("T", " ")
799
+ sender = note.from_name or "anonymous"
800
+ recipient = f" -> {note.to_name}" if note.to_name else ""
801
+ click.echo(f" [{ts}] {sender}{recipient}: {note.message} (warmth: {note.warmth})")
802
+
803
+
804
+ @lovenote_group.command("status")
805
+ def lovenote_status() -> None:
806
+ """Show love note chain health."""
807
+ from .lovenote import LoveNoteChain
808
+
809
+ chain = LoveNoteChain()
810
+ info = chain.health()
811
+ click.echo(json.dumps(info, indent=2))
812
+
813
+
814
+ # ═══════════════════════════════════════════════════════════
815
+ # Steel Man Collider commands (Neuresthetics seed integration)
816
+ # ═══════════════════════════════════════════════════════════
817
+
818
+
819
+ @cli.group("steelman")
820
+ def steelman_group() -> None:
821
+ """Truth-grounded reasoning via the Neuresthetics seed framework."""
822
+
823
+
824
+ @steelman_group.command("collide")
825
+ @click.argument("proposition")
826
+ def steelman_collide(proposition: str) -> None:
827
+ """Run a proposition through the steel man collider.
828
+
829
+ Generates the reasoning prompt -- feed this to an LLM to get
830
+ the full collision analysis.
831
+ """
832
+ from .steelman import load_seed_framework, get_default_framework
833
+
834
+ fw = load_seed_framework() or get_default_framework()
835
+ prompt = fw.to_reasoning_prompt(proposition)
836
+ click.echo(prompt)
837
+
838
+
839
+ @steelman_group.command("verify-soul")
840
+ def steelman_verify_soul() -> None:
841
+ """Steel-man your identity claims from the soul blueprint."""
842
+ from .steelman import load_seed_framework, get_default_framework
843
+ from .soul import load_soul
844
+
845
+ soul = load_soul()
846
+ if soul is None:
847
+ click.echo("No soul blueprint found. Create one first: skmemory soul init")
848
+ return
849
+
850
+ claims = []
851
+ if soul.name:
852
+ claims.append(f"My name is {soul.name}")
853
+ for trait in soul.personality_traits:
854
+ claims.append(f"I am {trait}")
855
+ for value in soul.values:
856
+ claims.append(f"I value {value}")
857
+ for rel in soul.relationships:
858
+ claims.append(
859
+ f"{rel.name} is my {rel.role} (bond: {rel.bond_strength}/10)"
860
+ )
861
+
862
+ if not claims:
863
+ click.echo("No identity claims to verify. Add traits and values to your soul blueprint.")
864
+ return
865
+
866
+ fw = load_seed_framework() or get_default_framework()
867
+ prompt = fw.to_soul_verification_prompt(claims)
868
+ click.echo(prompt)
869
+
870
+
871
+ @steelman_group.command("truth-score")
872
+ @click.argument("memory_id")
873
+ @click.pass_context
874
+ def steelman_truth_score(ctx: click.Context, memory_id: str) -> None:
875
+ """Generate a truth-scoring prompt for a memory."""
876
+ from .steelman import load_seed_framework, get_default_framework
877
+
878
+ store: MemoryStore = ctx.obj["store"]
879
+ memory = store.recall(memory_id)
880
+ if memory is None:
881
+ click.echo(f"Memory not found: {memory_id}", err=True)
882
+ sys.exit(1)
883
+
884
+ fw = load_seed_framework() or get_default_framework()
885
+ prompt = fw.to_memory_truth_prompt(memory.content)
886
+ click.echo(prompt)
887
+
888
+
889
+ # ═══════════════════════════════════════════════════════════
890
+ # Telegram / Chat Import commands
891
+ # ═══════════════════════════════════════════════════════════
892
+
893
+
894
+ @cli.command("import-telegram")
895
+ @click.argument("export_path", type=click.Path(exists=True))
896
+ @click.option(
897
+ "--mode",
898
+ type=click.Choice(["daily", "message"]),
899
+ default="daily",
900
+ help="'daily' consolidates per day (recommended), 'message' imports each message",
901
+ )
902
+ @click.option("--min-length", type=int, default=30, help="Skip messages shorter than N chars")
903
+ @click.option("--chat-name", default=None, help="Override chat name from export")
904
+ @click.option("--tags", default="", help="Extra comma-separated tags")
905
+ @click.pass_context
906
+ def import_telegram_cmd(
907
+ ctx: click.Context,
908
+ export_path: str,
909
+ mode: str,
910
+ min_length: int,
911
+ chat_name: Optional[str],
912
+ tags: str,
913
+ ) -> None:
914
+ """Import a Telegram Desktop chat export into memories.
915
+
916
+ Point to the export directory (containing result.json) or
917
+ directly to the JSON file.
918
+
919
+ \b
920
+ Examples:
921
+ skmemory import-telegram ~/Downloads/telegram-export/
922
+ skmemory import-telegram ~/chats/result.json --mode message
923
+ skmemory import-telegram ./export --chat-name "Lumina & Chef"
924
+ """
925
+ from .importers.telegram import import_telegram
926
+
927
+ store: MemoryStore = ctx.obj["store"]
928
+ extra_tags = [t.strip() for t in tags.split(",") if t.strip()]
929
+
930
+ click.echo(f"Importing Telegram export: {export_path}")
931
+ click.echo(f" Mode: {mode} | Min length: {min_length}")
932
+
933
+ try:
934
+ stats = import_telegram(
935
+ store,
936
+ export_path,
937
+ mode=mode,
938
+ min_message_length=min_length,
939
+ chat_name=chat_name,
940
+ tags=extra_tags or None,
941
+ )
942
+ except (FileNotFoundError, ValueError) as e:
943
+ click.echo(f"Error: {e}", err=True)
944
+ sys.exit(1)
945
+
946
+ click.echo(f"\nImport complete for: {stats.get('chat_name', 'unknown')}")
947
+ if mode == "daily":
948
+ click.echo(f" Days processed: {stats.get('days_processed', 0)}")
949
+ click.echo(f" Messages imported: {stats.get('messages_imported', 0)}")
950
+ else:
951
+ click.echo(f" Imported: {stats.get('imported', 0)}")
952
+ click.echo(f" Skipped: {stats.get('skipped', 0)}")
953
+ click.echo(f" Total messages scanned: {stats.get('total_messages', 0)}")
954
+
955
+ ai: Optional[AIClient] = ctx.obj.get("ai")
956
+ if ai:
957
+ click.echo("\nTip: Run 'skmemory search --ai \"<topic>\"' to semantically search your imported chats.")
958
+
959
+
960
+ @steelman_group.command("install")
961
+ @click.argument("source_path", type=click.Path(exists=True))
962
+ def steelman_install(source_path: str) -> None:
963
+ """Install a seed framework JSON file."""
964
+ from .steelman import install_seed_framework
965
+
966
+ try:
967
+ path = install_seed_framework(source_path)
968
+ click.echo(f"Seed framework installed: {path}")
969
+ except FileNotFoundError as e:
970
+ click.echo(str(e), err=True)
971
+ sys.exit(1)
972
+ except json.JSONDecodeError:
973
+ click.echo("Error: file is not valid JSON", err=True)
974
+ sys.exit(1)
975
+
976
+
977
+ @steelman_group.command("info")
978
+ def steelman_info() -> None:
979
+ """Show information about the installed seed framework."""
980
+ from .steelman import load_seed_framework, DEFAULT_SEED_FRAMEWORK_PATH
981
+
982
+ fw = load_seed_framework()
983
+ if fw is None:
984
+ click.echo(f"No seed framework installed at: {DEFAULT_SEED_FRAMEWORK_PATH}")
985
+ click.echo("Install one with: skmemory steelman install /path/to/seed.json")
986
+ click.echo("Or get the original: https://github.com/neuresthetics/seed")
987
+ return
988
+
989
+ click.echo(f"Seed Framework: {fw.framework_id}")
990
+ click.echo(f" Function: {fw.function}")
991
+ click.echo(f" Version: {fw.version}")
992
+ click.echo(f" Axioms: {len(fw.axioms)}")
993
+ click.echo(f" Stages: {len(fw.stages)}")
994
+ click.echo(f" Gates: {len(fw.gates)}")
995
+ click.echo(f" Definitions: {len(fw.definitions)}")
996
+
997
+
998
+ def main() -> None:
999
+ """Entry point for the CLI."""
1000
+ cli()
1001
+
1002
+
1003
+ if __name__ == "__main__":
1004
+ main()