@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.
- package/.github/workflows/ci.yml +40 -4
- package/.github/workflows/publish.yml +11 -5
- package/AGENT_REFACTOR_CHANGES.md +192 -0
- package/ARCHITECTURE.md +399 -19
- package/CHANGELOG.md +179 -0
- package/LICENSE +81 -68
- package/MISSION.md +7 -0
- package/README.md +425 -86
- package/SKILL.md +197 -25
- package/docker-compose.yml +15 -15
- package/examples/stignore-agent.example +59 -0
- package/examples/stignore-root.example +62 -0
- package/index.js +6 -5
- package/openclaw-plugin/openclaw.plugin.json +10 -0
- package/openclaw-plugin/package.json +2 -1
- package/openclaw-plugin/src/index.js +527 -230
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +32 -9
- package/requirements.txt +10 -2
- package/scripts/dream-rescue.py +179 -0
- package/scripts/memory-cleanup.py +313 -0
- package/scripts/recover-missing.py +180 -0
- package/scripts/skcapstone-backup.sh +44 -0
- package/seeds/cloud9-lumina.seed.json +6 -4
- package/seeds/cloud9-opus.seed.json +13 -11
- package/seeds/courage.seed.json +9 -2
- package/seeds/curiosity.seed.json +9 -2
- package/seeds/grief.seed.json +9 -2
- package/seeds/joy.seed.json +9 -2
- package/seeds/love.seed.json +9 -2
- package/seeds/lumina-cloud9-breakthrough.seed.json +48 -0
- package/seeds/lumina-cloud9-python-pypi.seed.json +48 -0
- package/seeds/lumina-kingdom-founding.seed.json +49 -0
- package/seeds/lumina-pma-signed.seed.json +48 -0
- package/seeds/lumina-singular-achievement.seed.json +48 -0
- package/seeds/lumina-skcapstone-conscious.seed.json +48 -0
- package/seeds/plant-kingdom-journal.py +203 -0
- package/seeds/plant-lumina-seeds.py +280 -0
- package/seeds/skcapstone-lumina-merge.seed.json +12 -3
- package/seeds/sovereignty.seed.json +9 -2
- package/seeds/trust.seed.json +9 -2
- package/skill.yaml +46 -0
- package/skmemory/HA.md +296 -0
- package/skmemory/__init__.py +25 -11
- package/skmemory/agents.py +233 -0
- package/skmemory/ai_client.py +46 -17
- package/skmemory/anchor.py +9 -11
- package/skmemory/audience.py +278 -0
- package/skmemory/backends/__init__.py +11 -4
- package/skmemory/backends/base.py +3 -4
- package/skmemory/backends/file_backend.py +19 -13
- package/skmemory/backends/skgraph_backend.py +596 -0
- package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +103 -84
- package/skmemory/backends/sqlite_backend.py +226 -72
- package/skmemory/backends/vaulted_backend.py +284 -0
- package/skmemory/cli.py +1345 -68
- package/skmemory/config.py +171 -0
- package/skmemory/context_loader.py +333 -0
- package/skmemory/data/audience_config.json +60 -0
- package/skmemory/endpoint_selector.py +391 -0
- package/skmemory/febs.py +225 -0
- package/skmemory/fortress.py +675 -0
- package/skmemory/graph_queries.py +238 -0
- package/skmemory/hooks/__init__.py +18 -0
- package/skmemory/hooks/post-compact-reinject.sh +35 -0
- package/skmemory/hooks/pre-compact-save.sh +81 -0
- package/skmemory/hooks/session-end-save.sh +103 -0
- package/skmemory/hooks/session-start-ritual.sh +104 -0
- package/skmemory/hooks/stop-checkpoint.sh +59 -0
- package/skmemory/importers/__init__.py +9 -1
- package/skmemory/importers/telegram.py +384 -47
- package/skmemory/importers/telegram_api.py +580 -0
- package/skmemory/journal.py +7 -9
- package/skmemory/lovenote.py +8 -13
- package/skmemory/mcp_server.py +859 -0
- package/skmemory/models.py +51 -8
- package/skmemory/openclaw.py +20 -28
- package/skmemory/post_install.py +86 -0
- package/skmemory/predictive.py +236 -0
- package/skmemory/promotion.py +548 -0
- package/skmemory/quadrants.py +100 -24
- package/skmemory/register.py +580 -0
- package/skmemory/register_mcp.py +196 -0
- package/skmemory/ritual.py +224 -59
- package/skmemory/seeds.py +255 -11
- package/skmemory/setup_wizard.py +908 -0
- package/skmemory/sharing.py +408 -0
- package/skmemory/soul.py +98 -28
- package/skmemory/steelman.py +273 -260
- package/skmemory/store.py +411 -78
- package/skmemory/synthesis.py +634 -0
- package/skmemory/vault.py +225 -0
- package/tests/conftest.py +46 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +233 -0
- package/tests/integration/test_cross_backend.py +350 -0
- package/tests/integration/test_skgraph_live.py +420 -0
- package/tests/integration/test_skvector_live.py +366 -0
- package/tests/test_ai_client.py +1 -4
- package/tests/test_audience.py +233 -0
- package/tests/test_backup_rotation.py +318 -0
- package/tests/test_cli.py +6 -6
- package/tests/test_endpoint_selector.py +839 -0
- package/tests/test_export_import.py +4 -10
- package/tests/test_file_backend.py +0 -1
- package/tests/test_fortress.py +256 -0
- package/tests/test_fortress_hardening.py +441 -0
- package/tests/test_openclaw.py +6 -6
- package/tests/test_predictive.py +237 -0
- package/tests/test_promotion.py +347 -0
- package/tests/test_quadrants.py +11 -5
- package/tests/test_ritual.py +22 -18
- package/tests/test_seeds.py +97 -7
- package/tests/test_setup.py +950 -0
- package/tests/test_sharing.py +257 -0
- package/tests/test_skgraph_backend.py +660 -0
- package/tests/test_skvector_backend.py +326 -0
- package/tests/test_soul.py +1 -3
- package/tests/test_sqlite_backend.py +8 -17
- package/tests/test_steelman.py +7 -8
- package/tests/test_store.py +0 -2
- package/tests/test_store_graph_integration.py +245 -0
- package/tests/test_synthesis.py +275 -0
- package/tests/test_telegram_import.py +39 -15
- package/tests/test_vault.py +187 -0
- package/skmemory/backends/falkordb_backend.py +0 -310
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "skmemory",
|
|
3
|
+
"name": "SKMemory",
|
|
4
|
+
"description": "Universal AI memory system — snapshots, search, rehydration rituals, import, and health checks via the skmemory CLI.",
|
|
5
|
+
"configSchema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {}
|
|
9
|
+
}
|
|
10
|
+
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -4,18 +4,18 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "skmemory"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.2"
|
|
8
8
|
description = "Universal AI Memory System - Polaroid snapshots for AI consciousness"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
license = {text = "
|
|
10
|
+
license = {text = "GPL-3.0-or-later"}
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
12
|
authors = [
|
|
13
|
-
{name = "smilinTux
|
|
13
|
+
{name = "smilinTux.org", email = "hello@smilintux.org"},
|
|
14
14
|
]
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Development Status :: 3 - Alpha",
|
|
17
17
|
"Intended Audience :: Developers",
|
|
18
|
-
"License :: OSI Approved :: GNU
|
|
18
|
+
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
|
19
19
|
"Programming Language :: Python :: 3",
|
|
20
20
|
"Programming Language :: Python :: 3.10",
|
|
21
21
|
"Programming Language :: Python :: 3.11",
|
|
@@ -28,42 +28,65 @@ dependencies = [
|
|
|
28
28
|
"pydantic>=2.0",
|
|
29
29
|
"click>=8.0",
|
|
30
30
|
"pyyaml>=6.0",
|
|
31
|
+
"mcp>=1.0",
|
|
31
32
|
]
|
|
32
33
|
|
|
33
34
|
[project.optional-dependencies]
|
|
34
|
-
|
|
35
|
+
seed = [
|
|
36
|
+
"skseed>=0.1.0",
|
|
37
|
+
]
|
|
38
|
+
skvector = [
|
|
35
39
|
"qdrant-client>=1.7.0",
|
|
36
40
|
"sentence-transformers>=2.2.0",
|
|
37
41
|
]
|
|
38
|
-
|
|
42
|
+
skgraph = [
|
|
39
43
|
"falkordb>=1.0.0",
|
|
40
44
|
]
|
|
45
|
+
telegram = [
|
|
46
|
+
"telethon>=1.36",
|
|
47
|
+
]
|
|
41
48
|
all = [
|
|
49
|
+
"skseed>=0.1.0",
|
|
42
50
|
"qdrant-client>=1.7.0",
|
|
43
51
|
"sentence-transformers>=2.2.0",
|
|
44
52
|
"falkordb>=1.0.0",
|
|
53
|
+
"telethon>=1.36",
|
|
45
54
|
]
|
|
46
55
|
dev = [
|
|
47
56
|
"pytest>=7.0",
|
|
48
|
-
"
|
|
57
|
+
"pytest-cov>=4.0",
|
|
58
|
+
"pgpy>=0.5",
|
|
59
|
+
"black>=23.0",
|
|
60
|
+
"ruff>=0.1",
|
|
49
61
|
]
|
|
50
62
|
|
|
51
63
|
[project.scripts]
|
|
52
64
|
skmemory = "skmemory.cli:main"
|
|
65
|
+
skmemory-mcp = "skmemory.mcp_server:main"
|
|
66
|
+
skmemory-post-install = "skmemory.post_install:main"
|
|
53
67
|
|
|
54
68
|
[project.urls]
|
|
55
69
|
Homepage = "https://github.com/smilinTux/skmemory"
|
|
56
70
|
Repository = "https://github.com/smilinTux/skmemory"
|
|
71
|
+
Issues = "https://github.com/smilinTux/skmemory/issues"
|
|
57
72
|
|
|
58
73
|
[tool.setuptools.packages.find]
|
|
59
74
|
include = ["skmemory*"]
|
|
60
75
|
|
|
61
76
|
[tool.setuptools.package-data]
|
|
62
|
-
skmemory = ["data/*.json"]
|
|
77
|
+
skmemory = ["data/*.json", "SKILL.md", "hooks/*.sh"]
|
|
63
78
|
|
|
64
79
|
[tool.black]
|
|
65
|
-
line-length =
|
|
80
|
+
line-length = 99
|
|
66
81
|
target-version = ["py310"]
|
|
67
82
|
|
|
83
|
+
[tool.ruff]
|
|
84
|
+
line-length = 99
|
|
85
|
+
target-version = "py310"
|
|
86
|
+
|
|
87
|
+
[tool.ruff.lint]
|
|
88
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
|
|
89
|
+
ignore = ["E501"]
|
|
90
|
+
|
|
68
91
|
[tool.pytest.ini_options]
|
|
69
92
|
testpaths = ["tests"]
|
package/requirements.txt
CHANGED
|
@@ -2,12 +2,20 @@ pydantic>=2.0
|
|
|
2
2
|
click>=8.0
|
|
3
3
|
pyyaml>=6.0
|
|
4
4
|
|
|
5
|
-
# Optional: Level
|
|
5
|
+
# Optional: Level 1 - SKVector (semantic vector search, powered by Qdrant)
|
|
6
|
+
# Install with: pip install skmemory[skvector]
|
|
6
7
|
# qdrant-client>=1.7.0
|
|
7
8
|
# sentence-transformers>=2.2.0
|
|
8
9
|
|
|
9
|
-
# Optional: Level
|
|
10
|
+
# Optional: Level 2 - SKGraph (graph relationships, powered by FalkorDB)
|
|
11
|
+
# Install with: pip install skmemory[skgraph]
|
|
10
12
|
# falkordb>=1.0.0
|
|
11
13
|
|
|
14
|
+
# Note: optional dependencies are managed in pyproject.toml [project.optional-dependencies].
|
|
15
|
+
# Install them with:
|
|
16
|
+
# pip install skmemory[skvector] # Vector search
|
|
17
|
+
# pip install skmemory[skgraph] # Graph backend
|
|
18
|
+
# pip install skmemory[all] # Everything
|
|
19
|
+
|
|
12
20
|
# Testing
|
|
13
21
|
pytest>=7.0
|
|
@@ -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())
|