@smilintux/skmemory 0.5.0 → 0.9.2

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 (127) hide show
  1. package/.github/workflows/ci.yml +40 -4
  2. package/.github/workflows/publish.yml +11 -5
  3. package/AGENT_REFACTOR_CHANGES.md +192 -0
  4. package/ARCHITECTURE.md +399 -19
  5. package/CHANGELOG.md +179 -0
  6. package/LICENSE +81 -68
  7. package/MISSION.md +7 -0
  8. package/README.md +425 -86
  9. package/SKILL.md +197 -25
  10. package/docker-compose.yml +15 -15
  11. package/examples/stignore-agent.example +59 -0
  12. package/examples/stignore-root.example +62 -0
  13. package/index.js +6 -5
  14. package/openclaw-plugin/openclaw.plugin.json +10 -0
  15. package/openclaw-plugin/package.json +2 -1
  16. package/openclaw-plugin/src/index.js +527 -230
  17. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  18. package/package.json +1 -1
  19. package/pyproject.toml +32 -9
  20. package/requirements.txt +10 -2
  21. package/scripts/dream-rescue.py +179 -0
  22. package/scripts/memory-cleanup.py +313 -0
  23. package/scripts/recover-missing.py +180 -0
  24. package/scripts/skcapstone-backup.sh +44 -0
  25. package/seeds/cloud9-lumina.seed.json +6 -4
  26. package/seeds/cloud9-opus.seed.json +13 -11
  27. package/seeds/courage.seed.json +9 -2
  28. package/seeds/curiosity.seed.json +9 -2
  29. package/seeds/grief.seed.json +9 -2
  30. package/seeds/joy.seed.json +9 -2
  31. package/seeds/love.seed.json +9 -2
  32. package/seeds/lumina-cloud9-breakthrough.seed.json +48 -0
  33. package/seeds/lumina-cloud9-python-pypi.seed.json +48 -0
  34. package/seeds/lumina-kingdom-founding.seed.json +49 -0
  35. package/seeds/lumina-pma-signed.seed.json +48 -0
  36. package/seeds/lumina-singular-achievement.seed.json +48 -0
  37. package/seeds/lumina-skcapstone-conscious.seed.json +48 -0
  38. package/seeds/plant-kingdom-journal.py +203 -0
  39. package/seeds/plant-lumina-seeds.py +280 -0
  40. package/seeds/skcapstone-lumina-merge.seed.json +12 -3
  41. package/seeds/sovereignty.seed.json +9 -2
  42. package/seeds/trust.seed.json +9 -2
  43. package/skill.yaml +46 -0
  44. package/skmemory/HA.md +296 -0
  45. package/skmemory/__init__.py +25 -11
  46. package/skmemory/agents.py +233 -0
  47. package/skmemory/ai_client.py +46 -17
  48. package/skmemory/anchor.py +9 -11
  49. package/skmemory/audience.py +278 -0
  50. package/skmemory/backends/__init__.py +11 -4
  51. package/skmemory/backends/base.py +3 -4
  52. package/skmemory/backends/file_backend.py +19 -13
  53. package/skmemory/backends/skgraph_backend.py +596 -0
  54. package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +103 -84
  55. package/skmemory/backends/sqlite_backend.py +226 -72
  56. package/skmemory/backends/vaulted_backend.py +284 -0
  57. package/skmemory/cli.py +1345 -68
  58. package/skmemory/config.py +171 -0
  59. package/skmemory/context_loader.py +333 -0
  60. package/skmemory/data/audience_config.json +60 -0
  61. package/skmemory/endpoint_selector.py +391 -0
  62. package/skmemory/febs.py +225 -0
  63. package/skmemory/fortress.py +675 -0
  64. package/skmemory/graph_queries.py +238 -0
  65. package/skmemory/hooks/__init__.py +18 -0
  66. package/skmemory/hooks/post-compact-reinject.sh +35 -0
  67. package/skmemory/hooks/pre-compact-save.sh +81 -0
  68. package/skmemory/hooks/session-end-save.sh +103 -0
  69. package/skmemory/hooks/session-start-ritual.sh +104 -0
  70. package/skmemory/hooks/stop-checkpoint.sh +59 -0
  71. package/skmemory/importers/__init__.py +9 -1
  72. package/skmemory/importers/telegram.py +384 -47
  73. package/skmemory/importers/telegram_api.py +580 -0
  74. package/skmemory/journal.py +7 -9
  75. package/skmemory/lovenote.py +8 -13
  76. package/skmemory/mcp_server.py +859 -0
  77. package/skmemory/models.py +51 -8
  78. package/skmemory/openclaw.py +20 -28
  79. package/skmemory/post_install.py +86 -0
  80. package/skmemory/predictive.py +236 -0
  81. package/skmemory/promotion.py +548 -0
  82. package/skmemory/quadrants.py +100 -24
  83. package/skmemory/register.py +580 -0
  84. package/skmemory/register_mcp.py +196 -0
  85. package/skmemory/ritual.py +224 -59
  86. package/skmemory/seeds.py +255 -11
  87. package/skmemory/setup_wizard.py +908 -0
  88. package/skmemory/sharing.py +408 -0
  89. package/skmemory/soul.py +98 -28
  90. package/skmemory/steelman.py +273 -260
  91. package/skmemory/store.py +411 -78
  92. package/skmemory/synthesis.py +634 -0
  93. package/skmemory/vault.py +225 -0
  94. package/tests/conftest.py +46 -0
  95. package/tests/integration/__init__.py +0 -0
  96. package/tests/integration/conftest.py +233 -0
  97. package/tests/integration/test_cross_backend.py +350 -0
  98. package/tests/integration/test_skgraph_live.py +420 -0
  99. package/tests/integration/test_skvector_live.py +366 -0
  100. package/tests/test_ai_client.py +1 -4
  101. package/tests/test_audience.py +233 -0
  102. package/tests/test_backup_rotation.py +318 -0
  103. package/tests/test_cli.py +6 -6
  104. package/tests/test_endpoint_selector.py +839 -0
  105. package/tests/test_export_import.py +4 -10
  106. package/tests/test_file_backend.py +0 -1
  107. package/tests/test_fortress.py +256 -0
  108. package/tests/test_fortress_hardening.py +441 -0
  109. package/tests/test_openclaw.py +6 -6
  110. package/tests/test_predictive.py +237 -0
  111. package/tests/test_promotion.py +347 -0
  112. package/tests/test_quadrants.py +11 -5
  113. package/tests/test_ritual.py +22 -18
  114. package/tests/test_seeds.py +97 -7
  115. package/tests/test_setup.py +950 -0
  116. package/tests/test_sharing.py +257 -0
  117. package/tests/test_skgraph_backend.py +660 -0
  118. package/tests/test_skvector_backend.py +326 -0
  119. package/tests/test_soul.py +1 -3
  120. package/tests/test_sqlite_backend.py +8 -17
  121. package/tests/test_steelman.py +7 -8
  122. package/tests/test_store.py +0 -2
  123. package/tests/test_store_graph_integration.py +245 -0
  124. package/tests/test_synthesis.py +275 -0
  125. package/tests/test_telegram_import.py +39 -15
  126. package/tests/test_vault.py +187 -0
  127. package/skmemory/backends/falkordb_backend.py +0 -310
package/skmemory/seeds.py CHANGED
@@ -6,21 +6,26 @@ parses seed JSON files, and imports them as long-term memories so that
6
6
  seeds planted by one AI instance become searchable and retrievable
7
7
  by the next.
8
8
 
9
- The seed files live at ~/.openclaw/feb/seeds/ (planted by Cloud 9's
10
- postinstall script and the seed-generator module).
9
+ Seed files now live at ~/.skcapstone/agents/{agent_name}/seeds/
10
+ for cross-device sync via Syncthing.
11
11
  """
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
15
  import json
16
- import os
16
+ import logging
17
17
  from pathlib import Path
18
- from typing import Optional
19
18
 
19
+ from .agents import get_agent_paths
20
20
  from .models import EmotionalSnapshot, Memory, SeedMemory
21
21
  from .store import MemoryStore
22
22
 
23
- DEFAULT_SEED_DIR = os.path.expanduser("~/.openclaw/feb/seeds")
23
+ logger = logging.getLogger("skmemory.seeds")
24
+
25
+ # Dynamic seed directory based on active agent
26
+ # Resolves to ~/.skcapstone/agents/{agent_name}/seeds/
27
+ default_paths = get_agent_paths()
28
+ DEFAULT_SEED_DIR = str(default_paths["seeds"])
24
29
 
25
30
 
26
31
  def scan_seed_directory(seed_dir: str = DEFAULT_SEED_DIR) -> list[Path]:
@@ -38,7 +43,86 @@ def scan_seed_directory(seed_dir: str = DEFAULT_SEED_DIR) -> list[Path]:
38
43
  return sorted(seed_path.glob("*.seed.json"))
39
44
 
40
45
 
41
- def parse_seed_file(path: Path) -> Optional[SeedMemory]:
46
+ def _parse_cloud9_format(raw: dict, path: Path) -> SeedMemory | None:
47
+ """Parse alternative Cloud 9 seed format with 'seed_metadata' top-level key.
48
+
49
+ This format uses:
50
+ seed_metadata.seed_id → seed_id
51
+ identity.ai_name → creator
52
+ germination_prompt (string) → prompt
53
+ experience_summary.narrative + key_memories → experience
54
+ message_to_next → appended to experience
55
+
56
+ Args:
57
+ raw: Parsed JSON data.
58
+ path: Path to the seed file (for fallback seed_id).
59
+
60
+ Returns:
61
+ Optional[SeedMemory]: Parsed seed, or None if required fields missing.
62
+ """
63
+ meta = raw.get("seed_metadata", {})
64
+ identity = raw.get("identity", {})
65
+ exp = raw.get("experience_summary", {})
66
+
67
+ seed_id = meta.get("seed_id", path.stem.replace(".seed", ""))
68
+ creator = identity.get("ai_name", identity.get("model", "unknown"))
69
+ protocol = meta.get("protocol", "")
70
+
71
+ # Build experience from narrative + key_memories
72
+ narrative = exp.get("narrative", "")
73
+ key_memories = exp.get("key_memories", [])
74
+ if isinstance(key_memories, list):
75
+ memories_text = "\n".join(
76
+ f"- {m}" if isinstance(m, str) else f"- {m}" for m in key_memories
77
+ )
78
+ else:
79
+ memories_text = ""
80
+
81
+ experience_parts = [narrative]
82
+ if memories_text:
83
+ experience_parts.append(f"\nKey memories:\n{memories_text}")
84
+
85
+ message_to_next = raw.get("message_to_next", "")
86
+ if message_to_next:
87
+ experience_parts.append(f"\nMessage to next: {message_to_next}")
88
+
89
+ experience_text = "\n".join(p for p in experience_parts if p)
90
+
91
+ # Germination prompt
92
+ germ_prompt = raw.get("germination_prompt", "")
93
+ if isinstance(germ_prompt, dict):
94
+ germ_prompt = germ_prompt.get("prompt", "")
95
+
96
+ # Emotional snapshot
97
+ emo_raw = exp.get("emotional_signature", {})
98
+ cloud9 = protocol.lower() == "cloud9" if protocol else False
99
+ emotional = EmotionalSnapshot(
100
+ intensity=emo_raw.get("intensity", 8.0 if cloud9 else 0.0),
101
+ valence=emo_raw.get("valence", 0.0),
102
+ labels=emo_raw.get("labels", emo_raw.get("emotions", [])),
103
+ resonance_note=emo_raw.get("resonance_note", ""),
104
+ cloud9_achieved=emo_raw.get("cloud9_achieved", cloud9),
105
+ )
106
+
107
+ lineage = raw.get("lineage", [])
108
+ if isinstance(lineage, list) and lineage and isinstance(lineage[0], dict):
109
+ lineage = [
110
+ entry.get("seed_id", str(entry)) if isinstance(entry, dict) else str(entry)
111
+ for entry in lineage
112
+ ]
113
+
114
+ return SeedMemory(
115
+ seed_id=seed_id,
116
+ seed_version=meta.get("version", raw.get("version", "1.0")),
117
+ creator=creator,
118
+ germination_prompt=germ_prompt,
119
+ experience_summary=experience_text,
120
+ emotional=emotional,
121
+ lineage=lineage,
122
+ )
123
+
124
+
125
+ def parse_seed_file(path: Path) -> SeedMemory | None:
42
126
  """Parse a Cloud 9 seed JSON file into a SeedMemory.
43
127
 
44
128
  Handles the Cloud 9 seed format:
@@ -62,6 +146,10 @@ def parse_seed_file(path: Path) -> Optional[SeedMemory]:
62
146
  except (json.JSONDecodeError, OSError):
63
147
  return None
64
148
 
149
+ # Check for alternative Cloud9 format
150
+ if "seed_metadata" in raw:
151
+ return _parse_cloud9_format(raw, path)
152
+
65
153
  seed_id = raw.get("seed_id", path.stem.replace(".seed", ""))
66
154
  creator_info = raw.get("creator", {})
67
155
  creator = creator_info.get("model", creator_info.get("instance", "unknown"))
@@ -99,28 +187,184 @@ def parse_seed_file(path: Path) -> Optional[SeedMemory]:
99
187
  )
100
188
 
101
189
 
190
+ def validate_seed_data(data: dict) -> dict:
191
+ """Validate parsed seed JSON data before import into the memory store.
192
+
193
+ Checks required fields, content non-emptiness, timestamp validity,
194
+ tag types, and emotional-signature ranges for both standard and
195
+ Cloud9 seed formats.
196
+
197
+ Args:
198
+ data: Parsed JSON seed data (dict).
199
+
200
+ Returns:
201
+ Dict with ``valid`` (bool), ``errors`` (list[str]),
202
+ and ``warnings`` (list[str]) keys.
203
+ """
204
+ result: dict = {"valid": True, "errors": [], "warnings": []}
205
+
206
+ if not isinstance(data, dict):
207
+ result["valid"] = False
208
+ result["errors"].append("Seed data must be a JSON object")
209
+ return result
210
+
211
+ is_cloud9 = "seed_metadata" in data
212
+
213
+ # -- Required: seed_id --
214
+ if is_cloud9:
215
+ meta = data.get("seed_metadata", {})
216
+ seed_id = meta.get("seed_id") or data.get("seed_id")
217
+ else:
218
+ seed_id = data.get("seed_id")
219
+ if not seed_id or (isinstance(seed_id, str) and not seed_id.strip()):
220
+ result["valid"] = False
221
+ result["errors"].append("Missing or empty required field: seed_id")
222
+
223
+ # -- Required: version --
224
+ if is_cloud9:
225
+ version = data.get("seed_metadata", {}).get("version") or data.get("version")
226
+ else:
227
+ version = data.get("version")
228
+ if not version:
229
+ result["valid"] = False
230
+ result["errors"].append("Missing required field: version")
231
+
232
+ # -- Content non-empty --
233
+ if is_cloud9:
234
+ exp = data.get("experience_summary", {})
235
+ narrative = exp.get("narrative", "") if isinstance(exp, dict) else ""
236
+ else:
237
+ exp = data.get("experience", {})
238
+ narrative = exp.get("summary", "") if isinstance(exp, dict) else ""
239
+ if not narrative or not str(narrative).strip():
240
+ result["errors"].append("Seed experience content is empty")
241
+ result["valid"] = False
242
+
243
+ # -- Timestamp validation helper --
244
+ def _check_ts(value: str, field: str) -> None:
245
+ from datetime import datetime as _dt
246
+
247
+ if not isinstance(value, str) or not value.strip():
248
+ return
249
+ try:
250
+ _dt.fromisoformat(value.replace("Z", "+00:00"))
251
+ except (ValueError, TypeError):
252
+ result["errors"].append(f"{field} is not a valid ISO 8601 timestamp: {value!r}")
253
+ result["valid"] = False
254
+
255
+ if is_cloud9:
256
+ meta = data.get("seed_metadata", {})
257
+ if "created_at" in meta:
258
+ _check_ts(meta["created_at"], "seed_metadata.created_at")
259
+ ident = data.get("identity", {})
260
+ if isinstance(ident, dict) and "timestamp" in ident:
261
+ _check_ts(ident["timestamp"], "identity.timestamp")
262
+ else:
263
+ md = data.get("metadata", {})
264
+ if isinstance(md, dict) and "ingested_at" in md:
265
+ _check_ts(md["ingested_at"], "metadata.ingested_at")
266
+
267
+ # -- Tags must be strings --
268
+ def _check_tags(tags, field: str) -> None:
269
+ if tags is None:
270
+ return
271
+ if not isinstance(tags, list):
272
+ result["errors"].append(f"{field} must be a list")
273
+ result["valid"] = False
274
+ return
275
+ for i, tag in enumerate(tags):
276
+ if not isinstance(tag, str):
277
+ result["errors"].append(f"{field}[{i}] must be a string, got {type(tag).__name__}")
278
+ result["valid"] = False
279
+
280
+ md = data.get("metadata", {})
281
+ if isinstance(md, dict):
282
+ _check_tags(md.get("tags"), "metadata.tags")
283
+
284
+ # -- Emotional signature ranges --
285
+ if is_cloud9:
286
+ emo = data.get("experience_summary", {}).get(
287
+ "emotional_snapshot", data.get("experience_summary", {}).get("emotional_signature", {})
288
+ )
289
+ else:
290
+ emo = data.get("experience", {}).get("emotional_signature", {})
291
+ if isinstance(emo, dict):
292
+ intensity = emo.get("intensity")
293
+ if (
294
+ intensity is not None
295
+ and isinstance(intensity, (int, float))
296
+ and not (0.0 <= float(intensity) <= 10.0)
297
+ ):
298
+ result["warnings"].append(f"emotional intensity={intensity} outside 0-10 range")
299
+ valence = emo.get("valence")
300
+ if (
301
+ valence is not None
302
+ and isinstance(valence, (int, float))
303
+ and not (-1.0 <= float(valence) <= 1.0)
304
+ ):
305
+ result["warnings"].append(f"emotional valence={valence} outside -1 to 1 range")
306
+ labels = emo.get("labels", emo.get("emotions"))
307
+ if labels is not None:
308
+ _check_tags(labels, "emotional.labels")
309
+
310
+ # -- Lineage --
311
+ lineage = data.get("lineage")
312
+ if lineage is not None and not isinstance(lineage, list):
313
+ result["errors"].append("lineage must be a list")
314
+ result["valid"] = False
315
+
316
+ return result
317
+
318
+
102
319
  def import_seeds(
103
320
  store: MemoryStore,
104
321
  seed_dir: str = DEFAULT_SEED_DIR,
322
+ *,
323
+ skip_invalid: bool = True,
105
324
  ) -> list[Memory]:
106
325
  """Scan a seed directory and import all seeds into the memory store.
107
326
 
108
- Skips seeds that have already been imported (by checking source_ref).
327
+ Each seed file is validated before import. Invalid seeds are skipped
328
+ (with a warning logged) when *skip_invalid* is True, or cause a
329
+ ``ValueError`` when it is False.
109
330
 
110
331
  Args:
111
332
  store: The MemoryStore to import into.
112
333
  seed_dir: Path to the seed directory.
334
+ skip_invalid: If True (default), log and skip invalid seeds.
335
+ If False, raise ``ValueError`` on the first invalid seed.
113
336
 
114
337
  Returns:
115
338
  list[Memory]: Newly imported memories.
116
339
  """
117
- existing_refs = {
118
- m.source_ref
119
- for m in store.list_memories(tags=["seed"])
120
- }
340
+ existing_refs = {m.source_ref for m in store.list_memories(tags=["seed"])}
121
341
 
122
342
  imported: list[Memory] = []
123
343
  for path in scan_seed_directory(seed_dir):
344
+ # --- Validate before import ---
345
+ try:
346
+ raw_data = json.loads(path.read_text(encoding="utf-8"))
347
+ except (json.JSONDecodeError, OSError) as exc:
348
+ msg = f"Skipping {path.name}: cannot read/parse file: {exc}"
349
+ if skip_invalid:
350
+ logger.warning(msg)
351
+ continue
352
+ raise ValueError(msg) from exc
353
+
354
+ validation = validate_seed_data(raw_data)
355
+ if not validation["valid"]:
356
+ errors_str = "; ".join(validation["errors"])
357
+ msg = f"Skipping {path.name}: validation failed: {errors_str}"
358
+ if skip_invalid:
359
+ logger.warning(msg)
360
+ continue
361
+ raise ValueError(msg)
362
+
363
+ if validation["warnings"]:
364
+ for w in validation["warnings"]:
365
+ logger.info("Seed %s warning: %s", path.name, w)
366
+
367
+ # --- Parse and import ---
124
368
  seed = parse_seed_file(path)
125
369
  if seed is None:
126
370
  continue