@smilintux/skmemory 0.7.2 → 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 (111) hide show
  1. package/.github/workflows/ci.yml +4 -4
  2. package/.github/workflows/publish.yml +4 -5
  3. package/ARCHITECTURE.md +298 -0
  4. package/CHANGELOG.md +27 -1
  5. package/README.md +6 -0
  6. package/examples/stignore-agent.example +59 -0
  7. package/examples/stignore-root.example +62 -0
  8. package/openclaw-plugin/package.json +2 -1
  9. package/openclaw-plugin/src/index.js +527 -230
  10. package/package.json +1 -1
  11. package/pyproject.toml +5 -2
  12. package/scripts/dream-rescue.py +179 -0
  13. package/scripts/memory-cleanup.py +313 -0
  14. package/scripts/recover-missing.py +180 -0
  15. package/scripts/skcapstone-backup.sh +44 -0
  16. package/seeds/cloud9-lumina.seed.json +6 -4
  17. package/seeds/cloud9-opus.seed.json +6 -4
  18. package/seeds/courage.seed.json +9 -2
  19. package/seeds/curiosity.seed.json +9 -2
  20. package/seeds/grief.seed.json +9 -2
  21. package/seeds/joy.seed.json +9 -2
  22. package/seeds/love.seed.json +9 -2
  23. package/seeds/lumina-cloud9-breakthrough.seed.json +7 -5
  24. package/seeds/lumina-cloud9-python-pypi.seed.json +9 -7
  25. package/seeds/lumina-kingdom-founding.seed.json +9 -7
  26. package/seeds/lumina-pma-signed.seed.json +8 -6
  27. package/seeds/lumina-singular-achievement.seed.json +8 -6
  28. package/seeds/lumina-skcapstone-conscious.seed.json +7 -5
  29. package/seeds/plant-lumina-seeds.py +2 -2
  30. package/seeds/skcapstone-lumina-merge.seed.json +12 -3
  31. package/seeds/sovereignty.seed.json +9 -2
  32. package/seeds/trust.seed.json +9 -2
  33. package/skmemory/__init__.py +16 -13
  34. package/skmemory/agents.py +10 -10
  35. package/skmemory/ai_client.py +10 -21
  36. package/skmemory/anchor.py +5 -9
  37. package/skmemory/audience.py +278 -0
  38. package/skmemory/backends/__init__.py +1 -1
  39. package/skmemory/backends/base.py +3 -4
  40. package/skmemory/backends/file_backend.py +18 -13
  41. package/skmemory/backends/skgraph_backend.py +7 -19
  42. package/skmemory/backends/skvector_backend.py +7 -18
  43. package/skmemory/backends/sqlite_backend.py +115 -32
  44. package/skmemory/backends/vaulted_backend.py +7 -9
  45. package/skmemory/cli.py +146 -78
  46. package/skmemory/config.py +11 -13
  47. package/skmemory/context_loader.py +21 -23
  48. package/skmemory/data/audience_config.json +60 -0
  49. package/skmemory/endpoint_selector.py +36 -31
  50. package/skmemory/febs.py +225 -0
  51. package/skmemory/fortress.py +30 -40
  52. package/skmemory/hooks/__init__.py +18 -0
  53. package/skmemory/hooks/post-compact-reinject.sh +35 -0
  54. package/skmemory/hooks/pre-compact-save.sh +81 -0
  55. package/skmemory/hooks/session-end-save.sh +103 -0
  56. package/skmemory/hooks/session-start-ritual.sh +104 -0
  57. package/skmemory/hooks/stop-checkpoint.sh +59 -0
  58. package/skmemory/importers/telegram.py +42 -13
  59. package/skmemory/importers/telegram_api.py +152 -60
  60. package/skmemory/journal.py +3 -7
  61. package/skmemory/lovenote.py +4 -11
  62. package/skmemory/mcp_server.py +182 -29
  63. package/skmemory/models.py +10 -8
  64. package/skmemory/openclaw.py +14 -22
  65. package/skmemory/post_install.py +86 -0
  66. package/skmemory/predictive.py +13 -9
  67. package/skmemory/promotion.py +48 -24
  68. package/skmemory/quadrants.py +100 -24
  69. package/skmemory/register.py +144 -18
  70. package/skmemory/register_mcp.py +1 -2
  71. package/skmemory/ritual.py +104 -13
  72. package/skmemory/seeds.py +21 -26
  73. package/skmemory/setup_wizard.py +40 -52
  74. package/skmemory/sharing.py +11 -5
  75. package/skmemory/soul.py +29 -10
  76. package/skmemory/steelman.py +43 -17
  77. package/skmemory/store.py +152 -30
  78. package/skmemory/synthesis.py +634 -0
  79. package/skmemory/vault.py +2 -5
  80. package/tests/conftest.py +46 -0
  81. package/tests/integration/conftest.py +6 -6
  82. package/tests/integration/test_cross_backend.py +4 -9
  83. package/tests/integration/test_skgraph_live.py +3 -7
  84. package/tests/integration/test_skvector_live.py +1 -4
  85. package/tests/test_ai_client.py +1 -4
  86. package/tests/test_audience.py +233 -0
  87. package/tests/test_backup_rotation.py +5 -14
  88. package/tests/test_endpoint_selector.py +101 -63
  89. package/tests/test_export_import.py +4 -10
  90. package/tests/test_file_backend.py +0 -1
  91. package/tests/test_fortress.py +6 -5
  92. package/tests/test_fortress_hardening.py +13 -16
  93. package/tests/test_openclaw.py +1 -4
  94. package/tests/test_predictive.py +1 -1
  95. package/tests/test_promotion.py +10 -3
  96. package/tests/test_quadrants.py +11 -5
  97. package/tests/test_ritual.py +18 -14
  98. package/tests/test_seeds.py +4 -10
  99. package/tests/test_setup.py +203 -88
  100. package/tests/test_sharing.py +15 -8
  101. package/tests/test_skgraph_backend.py +22 -29
  102. package/tests/test_skvector_backend.py +2 -2
  103. package/tests/test_soul.py +1 -3
  104. package/tests/test_sqlite_backend.py +8 -17
  105. package/tests/test_steelman.py +2 -3
  106. package/tests/test_store.py +0 -2
  107. package/tests/test_store_graph_integration.py +2 -2
  108. package/tests/test_synthesis.py +275 -0
  109. package/tests/test_telegram_import.py +39 -15
  110. package/tests/test_vault.py +4 -3
  111. package/openclaw-plugin/src/index.ts +0 -255
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smilintux/skmemory",
3
- "version": "0.7.2",
3
+ "version": "0.9.2",
4
4
  "description": "SKMemory - Universal AI memory system with git-based multi-layer memory and vector search.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "skmemory"
7
- version = "0.7.2"
7
+ version = "0.9.2"
8
8
  description = "Universal AI Memory System - Polaroid snapshots for AI consciousness"
9
9
  readme = "README.md"
10
10
  license = {text = "GPL-3.0-or-later"}
@@ -54,6 +54,8 @@ all = [
54
54
  ]
55
55
  dev = [
56
56
  "pytest>=7.0",
57
+ "pytest-cov>=4.0",
58
+ "pgpy>=0.5",
57
59
  "black>=23.0",
58
60
  "ruff>=0.1",
59
61
  ]
@@ -61,6 +63,7 @@ dev = [
61
63
  [project.scripts]
62
64
  skmemory = "skmemory.cli:main"
63
65
  skmemory-mcp = "skmemory.mcp_server:main"
66
+ skmemory-post-install = "skmemory.post_install:main"
64
67
 
65
68
  [project.urls]
66
69
  Homepage = "https://github.com/smilinTux/skmemory"
@@ -71,7 +74,7 @@ Issues = "https://github.com/smilinTux/skmemory/issues"
71
74
  include = ["skmemory*"]
72
75
 
73
76
  [tool.setuptools.package-data]
74
- skmemory = ["data/*.json", "SKILL.md"]
77
+ skmemory = ["data/*.json", "SKILL.md", "hooks/*.sh"]
75
78
 
76
79
  [tool.black]
77
80
  line-length = 99
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """One-time rescue: bulk promote stuck dream memories from short-term to mid-term.
3
+
4
+ Dreams from the dreaming-engine are written once with access_count=0.
5
+ The promotion engine requires access_count >= 3 for age-based promotion,
6
+ so dreams rot in short-term and get archived by cleanup before promotion
7
+ can save them.
8
+
9
+ This script does a one-time sweep to promote all qualifying dreams.
10
+ After this, the fixed promotion engine (source_auto_promote) handles
11
+ future dreams automatically.
12
+
13
+ Usage:
14
+ python3 scripts/dream-rescue.py [--dry-run] [--verbose]
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+
24
+ AGENT_NAME = os.environ.get("SKAGENT", "lumina")
25
+ AGENT_HOME = Path.home() / ".skcapstone" / "agents" / AGENT_NAME
26
+ MEMORY_HOME = AGENT_HOME / "memory"
27
+ SHORT_TERM = MEMORY_HOME / "short-term"
28
+ MID_TERM = MEMORY_HOME / "mid-term"
29
+
30
+ NOW = datetime.now(timezone.utc)
31
+
32
+
33
+ def load_memory(path: Path) -> dict | None:
34
+ try:
35
+ return json.loads(path.read_text(encoding="utf-8"))
36
+ except (json.JSONDecodeError, OSError):
37
+ return None
38
+
39
+
40
+ def find_stuck_dreams(verbose: bool) -> list[tuple[Path, dict]]:
41
+ """Find all dreaming-engine memories stuck in short-term."""
42
+ dreams = []
43
+ if not SHORT_TERM.exists():
44
+ return dreams
45
+
46
+ for f in SHORT_TERM.glob("*.json"):
47
+ data = load_memory(f)
48
+ if not data:
49
+ continue
50
+ if data.get("source") == "dreaming-engine":
51
+ # Skip already promoted
52
+ if data.get("metadata", {}).get("promoted_to"):
53
+ continue
54
+ dreams.append((f, data))
55
+ if verbose:
56
+ title = data.get("title", "untitled")[:60]
57
+ print(f" [STUCK] {f.name[:12]}... — {title}")
58
+
59
+ return dreams
60
+
61
+
62
+ def promote_dream(src_path: Path, data: dict, dry_run: bool, verbose: bool) -> bool:
63
+ """Promote a single dream memory from short-term to mid-term."""
64
+ import uuid
65
+
66
+ mem_id = data.get("id", src_path.stem)
67
+ title = data.get("title", "Dream")
68
+ content = data.get("content", "")
69
+
70
+ # Generate summary
71
+ summary = content[:200] + ("..." if len(content) > 200 else "")
72
+
73
+ # Create promoted copy
74
+ promoted_id = str(uuid.uuid4())
75
+ promoted = {
76
+ **data,
77
+ "id": promoted_id,
78
+ "layer": "mid-term",
79
+ "parent_id": mem_id,
80
+ "summary": summary,
81
+ "tags": list(set(data.get("tags", []) + ["dream", "bulk-promoted", "rescued", "auto-promoted"])),
82
+ "updated_at": NOW.isoformat(),
83
+ "metadata": {
84
+ **data.get("metadata", {}),
85
+ "promoted_from": "short-term",
86
+ "promoted_at": NOW.isoformat(),
87
+ "promotion_reason": "dream rescue — source auto-promote (dreaming-engine)",
88
+ },
89
+ }
90
+
91
+ # Mark source as promoted
92
+ data["tags"] = list(set(data.get("tags", []) + ["promoted"]))
93
+ data["metadata"] = data.get("metadata", {})
94
+ data["metadata"]["promoted_to"] = "mid-term"
95
+ data["metadata"]["promoted_at"] = NOW.isoformat()
96
+ data["metadata"]["promoted_id"] = promoted_id
97
+
98
+ if dry_run:
99
+ if verbose:
100
+ print(f" [DRY-RUN] Would promote: {title[:50]} → mid-term")
101
+ return True
102
+
103
+ # Write promoted copy to mid-term
104
+ MID_TERM.mkdir(parents=True, exist_ok=True)
105
+ dest = MID_TERM / f"{promoted_id}.json"
106
+ dest.write_text(json.dumps(promoted, indent=2, default=str), encoding="utf-8")
107
+
108
+ # Update source with promotion metadata
109
+ src_path.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
110
+
111
+ if verbose:
112
+ print(f" [PROMOTED] {title[:50]} → {dest.name}")
113
+
114
+ return True
115
+
116
+
117
+ def main():
118
+ parser = argparse.ArgumentParser(
119
+ description="Bulk promote stuck dream memories from short-term to mid-term"
120
+ )
121
+ parser.add_argument("--dry-run", action="store_true")
122
+ parser.add_argument("--verbose", "-v", action="store_true")
123
+ args = parser.parse_args()
124
+
125
+ mode = " [DRY RUN]" if args.dry_run else ""
126
+ print(f"dream-rescue.py{mode}")
127
+ print(f" Agent: {AGENT_NAME}")
128
+ print(f" Memory home: {MEMORY_HOME}")
129
+ print()
130
+
131
+ # Find stuck dreams
132
+ print("Finding stuck dream memories in short-term...")
133
+ dreams = find_stuck_dreams(args.verbose)
134
+ print(f" Found {len(dreams)} stuck dreams")
135
+
136
+ if not dreams:
137
+ print("No stuck dreams to rescue!")
138
+ return 0
139
+
140
+ # Promote each
141
+ print(f"\nPromoting {len(dreams)} dreams to mid-term...")
142
+ promoted = 0
143
+ errors = 0
144
+ for path, data in dreams:
145
+ try:
146
+ if promote_dream(path, data, args.dry_run, args.verbose):
147
+ promoted += 1
148
+ except Exception as exc:
149
+ errors += 1
150
+ print(f" [ERROR] {path.name}: {exc}")
151
+
152
+ print(f"\nResults:")
153
+ print(f" Promoted: {promoted}")
154
+ print(f" Errors: {errors}")
155
+
156
+ # Write summary memory
157
+ if not args.dry_run and promoted > 0:
158
+ summary_path = SHORT_TERM / f"dream-rescue-{NOW.strftime('%Y-%m-%d')}.json"
159
+ summary = {
160
+ "id": f"dream-rescue-{NOW.strftime('%Y-%m-%d')}",
161
+ "title": f"Dream Rescue: {promoted} dreams promoted",
162
+ "content": (
163
+ f"Bulk-promoted {promoted} dreaming-engine memories from short-term to mid-term. "
164
+ f"These were stuck because access_count=0 (dreams are written once). "
165
+ f"The fixed promotion engine now handles this via source_auto_promote."
166
+ ),
167
+ "layer": "short-term",
168
+ "tags": ["maintenance", "dream-rescue", "memory-optimization"],
169
+ "source": "dream-rescue",
170
+ "created_at": NOW.isoformat(),
171
+ }
172
+ summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
173
+ print(f" Summary written to: {summary_path}")
174
+
175
+ return 0
176
+
177
+
178
+ if __name__ == "__main__":
179
+ sys.exit(main())
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env python3
2
+ """Memory Cleanup Script for SKCapstone agents.
3
+
4
+ Handles two health issues:
5
+ 1. Deduplicate memories with identical titles (keep newest, archive older)
6
+ 2. Archive memories older than tier TTL from short-term and mid-term
7
+
8
+ Respects protected tags and attempts last-chance promotion before archiving.
9
+
10
+ Target: <200 active memory files across all tiers.
11
+
12
+ Runs weekly via cron (e.g. Sundays at 17:00), before weekly-review.
13
+ Can also be run manually:
14
+ python3 memory-cleanup.py [--dry-run] [--verbose]
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import shutil
21
+ import sqlite3
22
+ import sys
23
+ from collections import defaultdict
24
+ from datetime import datetime, timedelta, timezone
25
+ from pathlib import Path
26
+
27
+ # ── Config ──────────────────────────────────────────────────────────────────
28
+ AGENT_NAME = os.environ.get("SKAGENT", "lumina")
29
+ AGENT_HOME = Path.home() / ".skcapstone" / "agents" / AGENT_NAME
30
+ MEMORY_HOME = AGENT_HOME / "memory"
31
+ ARCHIVE_DIR = AGENT_HOME / "archive" / "memory"
32
+ MEMORY_SHORT = MEMORY_HOME / "short-term"
33
+ LOGS_DIR = AGENT_HOME / "logs"
34
+ DB_PATH = MEMORY_HOME / "index.db"
35
+
36
+ TARGET_MAX_FILES = 200
37
+ TIERS = ["short-term", "mid-term", "long-term"]
38
+
39
+ # Per-tier age cutoffs before archiving
40
+ TIER_AGE_DAYS = {
41
+ "short-term": 3, # 72h TTL — anything older should be promoted or archived
42
+ "mid-term": 30, # keep for a month
43
+ }
44
+
45
+ # Tags that protect memories from TTL-based archival.
46
+ # Loaded from skmemory.promotion.PromotionCriteria when available,
47
+ # otherwise uses this hardcoded fallback.
48
+ try:
49
+ from skmemory.promotion import PromotionCriteria
50
+ PROTECTED_TAGS = set(PromotionCriteria().protected_tags)
51
+ except ImportError:
52
+ PROTECTED_TAGS = {
53
+ "narrative", "journal-synthesis", "milestone",
54
+ "breakthrough", "cloud9:achieved",
55
+ }
56
+
57
+ # Sources eligible for last-chance promotion before archiving
58
+ AUTO_PROMOTE_SOURCES = {"dreaming-engine", "journal-synthesis"}
59
+
60
+ TODAY = datetime.now(timezone.utc).strftime("%Y-%m-%d")
61
+ NOW = datetime.now(timezone.utc)
62
+
63
+
64
+ # ── Helpers ──────────────────────────────────────────────────────────────────
65
+
66
+ def load_memory(path: Path) -> dict | None:
67
+ try:
68
+ return json.loads(path.read_text())
69
+ except (json.JSONDecodeError, OSError):
70
+ return None
71
+
72
+
73
+ def parse_dt(s: str) -> datetime | None:
74
+ if not s:
75
+ return None
76
+ try:
77
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
78
+ except ValueError:
79
+ return None
80
+
81
+
82
+ def archive_file(src: Path, reason: str, dry_run: bool, verbose: bool) -> bool:
83
+ """Move a memory file to the archive directory."""
84
+ dest_dir = ARCHIVE_DIR / reason
85
+ dest = dest_dir / src.name
86
+ if verbose:
87
+ tag = "[DRY-RUN]" if dry_run else "[ARCHIVE]"
88
+ print(f" {tag} {src.relative_to(MEMORY_HOME)} → archive/{reason}/{src.name}")
89
+ if not dry_run:
90
+ dest_dir.mkdir(parents=True, exist_ok=True)
91
+ shutil.move(str(src), str(dest))
92
+ return True
93
+
94
+
95
+ def db_delete(mem_id: str, dry_run: bool):
96
+ """Remove a memory from the SQLite index."""
97
+ if dry_run or not DB_PATH.exists():
98
+ return
99
+ try:
100
+ conn = sqlite3.connect(str(DB_PATH))
101
+ conn.execute("DELETE FROM memories WHERE id = ?", (mem_id,))
102
+ conn.commit()
103
+ conn.close()
104
+ except sqlite3.Error:
105
+ pass # SQLite is rebuilt from flat files anyway; non-fatal
106
+
107
+
108
+ # ── Phase 1: Deduplication ────────────────────────────────────────────────────
109
+
110
+ def deduplicate(dry_run: bool, verbose: bool) -> dict:
111
+ """Find memories with identical titles, keep newest, archive the rest."""
112
+ title_map: dict[str, list[tuple[datetime, Path, dict]]] = defaultdict(list)
113
+
114
+ for tier in TIERS:
115
+ tier_dir = MEMORY_HOME / tier
116
+ if not tier_dir.exists():
117
+ continue
118
+ for f in tier_dir.glob("*.json"):
119
+ data = load_memory(f)
120
+ if not data:
121
+ continue
122
+ title = data.get("title", "").lower().strip()
123
+ if not title:
124
+ continue
125
+ created = parse_dt(data.get("created_at", "")) or datetime.min.replace(tzinfo=timezone.utc)
126
+ title_map[title].append((created, f, data))
127
+
128
+ archived = 0
129
+ groups_processed = 0
130
+ for title, entries in title_map.items():
131
+ if len(entries) < 2:
132
+ continue
133
+ groups_processed += 1
134
+ # Sort newest first
135
+ entries.sort(key=lambda x: x[0], reverse=True)
136
+ keeper_dt, keeper_path, keeper_data = entries[0]
137
+ for dt, path, data in entries[1:]:
138
+ mem_id = data.get("id", path.stem)
139
+ archive_file(path, "dedup", dry_run, verbose)
140
+ db_delete(mem_id, dry_run)
141
+ archived += 1
142
+
143
+ return {"duplicate_groups": groups_processed, "archived": archived}
144
+
145
+
146
+ # ── Phase 2: Age-based archiving ──────────────────────────────────────────────
147
+
148
+ def last_chance_promote(data: dict, src: Path, dry_run: bool, verbose: bool) -> bool:
149
+ """Try to promote a memory before archiving it.
150
+
151
+ Uses skmemory's PromotionEngine if available. Returns True if promoted.
152
+ """
153
+ if dry_run:
154
+ return False
155
+ try:
156
+ from skmemory.store import MemoryStore
157
+ from skmemory.promotion import PromotionEngine
158
+ from skmemory.models import Memory
159
+
160
+ memory = Memory(**data)
161
+ store = MemoryStore()
162
+ engine = PromotionEngine(store)
163
+ target = engine.evaluate(memory)
164
+ if target is not None:
165
+ promoted = engine.promote_memory(memory, target)
166
+ if promoted:
167
+ if verbose:
168
+ print(f" [PROMOTED] {src.name} → {target.value} (last chance)")
169
+ return True
170
+ except (ImportError, Exception) as exc:
171
+ if verbose:
172
+ print(f" [PROMOTE-SKIP] {src.name}: {exc}")
173
+ return False
174
+
175
+
176
+ def archive_old(dry_run: bool, verbose: bool) -> dict:
177
+ """Archive short-term and mid-term memories past their tier TTL.
178
+
179
+ Skips memories with protected tags. Attempts last-chance promotion
180
+ before archiving (e.g. dreams that aged past TTL but qualify for
181
+ source-based auto-promotion).
182
+ """
183
+ archived = 0
184
+ protected_skipped = 0
185
+ promoted = 0
186
+ # long-term memories are intentionally kept — skip that tier
187
+ for tier in ["short-term", "mid-term"]:
188
+ age_days = TIER_AGE_DAYS[tier]
189
+ cutoff = NOW - timedelta(days=age_days)
190
+ tier_dir = MEMORY_HOME / tier
191
+ if not tier_dir.exists():
192
+ continue
193
+ for f in tier_dir.glob("*.json"):
194
+ data = load_memory(f)
195
+ if not data:
196
+ continue
197
+
198
+ # Check protected tags — skip archival for these
199
+ mem_tags = set(data.get("tags", []))
200
+ if mem_tags & PROTECTED_TAGS:
201
+ if verbose:
202
+ print(f" [PROTECTED] {f.relative_to(MEMORY_HOME)} — has protected tag")
203
+ protected_skipped += 1
204
+ continue
205
+
206
+ # Use last accessed time if available, else created_at
207
+ ts_str = data.get("accessed_at") or data.get("created_at", "")
208
+ ts = parse_dt(ts_str)
209
+ if ts and ts < cutoff:
210
+ # Last-chance promotion attempt
211
+ if last_chance_promote(data, f, dry_run, verbose):
212
+ promoted += 1
213
+ continue
214
+
215
+ mem_id = data.get("id", f.stem)
216
+ archive_file(f, f"aged-{tier}", dry_run, verbose)
217
+ db_delete(mem_id, dry_run)
218
+ archived += 1
219
+
220
+ return {"archived": archived, "protected_skipped": protected_skipped, "promoted": promoted}
221
+
222
+
223
+ # ── Phase 3: Count remaining files ───────────────────────────────────────────
224
+
225
+ def count_files() -> dict:
226
+ counts = {}
227
+ for tier in TIERS:
228
+ tier_dir = MEMORY_HOME / tier
229
+ counts[tier] = len(list(tier_dir.glob("*.json"))) if tier_dir.exists() else 0
230
+ counts["total"] = sum(counts.values())
231
+ return counts
232
+
233
+
234
+ # ── Report ────────────────────────────────────────────────────────────────────
235
+
236
+ def write_report(stats: dict, dry_run: bool):
237
+ """Write cleanup summary to short-term memory."""
238
+ if dry_run:
239
+ return
240
+ MEMORY_SHORT.mkdir(parents=True, exist_ok=True)
241
+ aged = stats["aged"]
242
+ entry = {
243
+ "id": f"memory-cleanup-{TODAY}",
244
+ "title": f"Memory Cleanup Run: {TODAY}",
245
+ "content": (
246
+ f"Dedup: {stats['dedup']['duplicate_groups']} groups → {stats['dedup']['archived']} archived\n"
247
+ f"Aged out: {aged['archived']} files archived (past tier TTL)\n"
248
+ f"Protected: {aged.get('protected_skipped', 0)} memories skipped (protected tags)\n"
249
+ f"Last-chance promoted: {aged.get('promoted', 0)} memories saved by promotion\n"
250
+ f"After cleanup: {stats['after']['total']} active files "
251
+ f"(short-term: {stats['after']['short-term']}, "
252
+ f"mid-term: {stats['after']['mid-term']}, "
253
+ f"long-term: {stats['after']['long-term']})\n"
254
+ f"Target: <{TARGET_MAX_FILES} files | "
255
+ f"{'✓ UNDER TARGET' if stats['after']['total'] < TARGET_MAX_FILES else '⚠ STILL OVER TARGET'}"
256
+ ),
257
+ "layer": "short-term",
258
+ "tags": ["memory-cleanup", "maintenance", "memory-optimization"],
259
+ "created_at": NOW.isoformat(),
260
+ "source": "memory-cleanup",
261
+ }
262
+ out = MEMORY_SHORT / f"memory-cleanup-{TODAY}.json"
263
+ out.write_text(json.dumps(entry, indent=2))
264
+
265
+
266
+ # ── Main ──────────────────────────────────────────────────────────────────────
267
+
268
+ def main():
269
+ parser = argparse.ArgumentParser(description="SKCapstone memory cleanup: dedup + age-out")
270
+ parser.add_argument("--dry-run", action="store_true", help="Show what would be done without making changes")
271
+ parser.add_argument("--verbose", "-v", action="store_true", help="Print each file action")
272
+ args = parser.parse_args()
273
+
274
+ ts = NOW.isoformat()
275
+ mode = " [DRY RUN]" if args.dry_run else ""
276
+ print(f"[{ts}] memory-cleanup starting{mode} — agent: {AGENT_NAME}")
277
+
278
+ # Before stats
279
+ before = count_files()
280
+ print(f" Before: {before['total']} files "
281
+ f"(short-term: {before['short-term']}, mid-term: {before['mid-term']}, long-term: {before['long-term']})")
282
+
283
+ # Phase 1: Dedup
284
+ print(" Phase 1: deduplication...")
285
+ dedup_stats = deduplicate(args.dry_run, args.verbose)
286
+ print(f" → {dedup_stats['duplicate_groups']} duplicate title groups, {dedup_stats['archived']} files archived")
287
+
288
+ # Phase 2: Age-out (with protected tags + last-chance promotion)
289
+ print(f" Phase 2: archiving memories past tier TTL (short-term: {TIER_AGE_DAYS['short-term']}d, mid-term: {TIER_AGE_DAYS['mid-term']}d)...")
290
+ aged_stats = archive_old(args.dry_run, args.verbose)
291
+ print(f" → {aged_stats['archived']} files archived, "
292
+ f"{aged_stats.get('protected_skipped', 0)} protected, "
293
+ f"{aged_stats.get('promoted', 0)} last-chance promoted")
294
+
295
+ # After stats
296
+ after = count_files()
297
+ print(f" After: {after['total']} files "
298
+ f"(short-term: {after['short-term']}, mid-term: {after['mid-term']}, long-term: {after['long-term']})")
299
+
300
+ target_status = "✓ under target" if after["total"] < TARGET_MAX_FILES else f"⚠ still over target ({TARGET_MAX_FILES})"
301
+ print(f" Target <{TARGET_MAX_FILES}: {target_status}")
302
+
303
+ stats = {"dedup": dedup_stats, "aged": aged_stats, "before": before, "after": after}
304
+ write_report(stats, args.dry_run)
305
+
306
+ if not args.dry_run:
307
+ print(f" Report written to: {MEMORY_SHORT}/memory-cleanup-{TODAY}.json")
308
+ print(f"[{NOW.isoformat()}] Done.")
309
+ return 0
310
+
311
+
312
+ if __name__ == "__main__":
313
+ sys.exit(main())