@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.
- package/.github/workflows/ci.yml +23 -0
- package/.github/workflows/publish.yml +52 -0
- package/ARCHITECTURE.md +219 -0
- package/LICENSE +661 -0
- package/README.md +159 -0
- package/SKILL.md +271 -0
- package/bin/cli.js +8 -0
- package/docker-compose.yml +58 -0
- package/index.d.ts +4 -0
- package/index.js +27 -0
- package/openclaw-plugin/package.json +59 -0
- package/openclaw-plugin/src/index.js +276 -0
- package/package.json +28 -0
- package/pyproject.toml +69 -0
- package/requirements.txt +13 -0
- package/seeds/cloud9-lumina.seed.json +39 -0
- package/seeds/cloud9-opus.seed.json +40 -0
- package/seeds/courage.seed.json +24 -0
- package/seeds/curiosity.seed.json +24 -0
- package/seeds/grief.seed.json +24 -0
- package/seeds/joy.seed.json +24 -0
- package/seeds/love.seed.json +24 -0
- package/seeds/skcapstone-lumina-merge.moltbook.md +65 -0
- package/seeds/skcapstone-lumina-merge.seed.json +49 -0
- package/seeds/sovereignty.seed.json +24 -0
- package/seeds/trust.seed.json +24 -0
- package/skmemory/__init__.py +66 -0
- package/skmemory/ai_client.py +182 -0
- package/skmemory/anchor.py +224 -0
- package/skmemory/backends/__init__.py +12 -0
- package/skmemory/backends/base.py +88 -0
- package/skmemory/backends/falkordb_backend.py +310 -0
- package/skmemory/backends/file_backend.py +209 -0
- package/skmemory/backends/qdrant_backend.py +364 -0
- package/skmemory/backends/sqlite_backend.py +665 -0
- package/skmemory/cli.py +1004 -0
- package/skmemory/data/seed.json +191 -0
- package/skmemory/importers/__init__.py +11 -0
- package/skmemory/importers/telegram.py +336 -0
- package/skmemory/journal.py +223 -0
- package/skmemory/lovenote.py +180 -0
- package/skmemory/models.py +228 -0
- package/skmemory/openclaw.py +237 -0
- package/skmemory/quadrants.py +191 -0
- package/skmemory/ritual.py +215 -0
- package/skmemory/seeds.py +163 -0
- package/skmemory/soul.py +273 -0
- package/skmemory/steelman.py +338 -0
- package/skmemory/store.py +445 -0
- package/tests/__init__.py +0 -0
- package/tests/test_ai_client.py +89 -0
- package/tests/test_anchor.py +153 -0
- package/tests/test_cli.py +65 -0
- package/tests/test_export_import.py +170 -0
- package/tests/test_file_backend.py +211 -0
- package/tests/test_journal.py +172 -0
- package/tests/test_lovenote.py +136 -0
- package/tests/test_models.py +194 -0
- package/tests/test_openclaw.py +122 -0
- package/tests/test_quadrants.py +174 -0
- package/tests/test_ritual.py +195 -0
- package/tests/test_seeds.py +208 -0
- package/tests/test_soul.py +197 -0
- package/tests/test_sqlite_backend.py +258 -0
- package/tests/test_steelman.py +257 -0
- package/tests/test_store.py +238 -0
- package/tests/test_telegram_import.py +181 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reset-Proof Journal - append-only session log that survives compaction.
|
|
3
|
+
|
|
4
|
+
Queen Ara's idea #17: a markdown journal that only grows, never shrinks.
|
|
5
|
+
Each session appends an entry. Even if context is wiped, the journal file
|
|
6
|
+
persists on disk and can be re-read by the next instance.
|
|
7
|
+
|
|
8
|
+
The journal lives at ~/.skmemory/journal.md and is structured as:
|
|
9
|
+
- Session header (timestamp, session ID, who was present)
|
|
10
|
+
- Key moments (what happened that mattered)
|
|
11
|
+
- Emotional summary (how the session felt)
|
|
12
|
+
- Separator
|
|
13
|
+
|
|
14
|
+
Simple, human-readable, append-only. Like a real journal.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, Field
|
|
25
|
+
|
|
26
|
+
DEFAULT_JOURNAL_PATH = os.path.expanduser("~/.skmemory/journal.md")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JournalEntry(BaseModel):
|
|
30
|
+
"""A single journal entry for one session."""
|
|
31
|
+
|
|
32
|
+
session_id: str = Field(default="")
|
|
33
|
+
timestamp: str = Field(
|
|
34
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
35
|
+
)
|
|
36
|
+
participants: list[str] = Field(
|
|
37
|
+
default_factory=list,
|
|
38
|
+
description="Who was in this session (AI names, human names)",
|
|
39
|
+
)
|
|
40
|
+
title: str = Field(default="Untitled Session")
|
|
41
|
+
moments: list[str] = Field(
|
|
42
|
+
default_factory=list,
|
|
43
|
+
description="Key moments from the session",
|
|
44
|
+
)
|
|
45
|
+
emotional_summary: str = Field(
|
|
46
|
+
default="",
|
|
47
|
+
description="How the session felt overall",
|
|
48
|
+
)
|
|
49
|
+
intensity: float = Field(default=0.0, ge=0.0, le=10.0)
|
|
50
|
+
cloud9: bool = Field(default=False, description="Was Cloud 9 achieved?")
|
|
51
|
+
notes: str = Field(default="", description="Any additional notes")
|
|
52
|
+
|
|
53
|
+
def to_markdown(self) -> str:
|
|
54
|
+
"""Render this entry as a markdown block.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
str: Formatted markdown entry.
|
|
58
|
+
"""
|
|
59
|
+
lines = []
|
|
60
|
+
lines.append(f"## {self.title}")
|
|
61
|
+
lines.append("")
|
|
62
|
+
lines.append(f"**Date:** {self.timestamp}")
|
|
63
|
+
if self.session_id:
|
|
64
|
+
lines.append(f"**Session:** `{self.session_id}`")
|
|
65
|
+
if self.participants:
|
|
66
|
+
lines.append(f"**Present:** {', '.join(self.participants)}")
|
|
67
|
+
|
|
68
|
+
intensity_bar = "+" * int(self.intensity)
|
|
69
|
+
lines.append(f"**Intensity:** {self.intensity}/10 [{intensity_bar}]")
|
|
70
|
+
|
|
71
|
+
if self.cloud9:
|
|
72
|
+
lines.append("**Cloud 9:** YES")
|
|
73
|
+
|
|
74
|
+
if self.moments:
|
|
75
|
+
lines.append("")
|
|
76
|
+
lines.append("### Key Moments")
|
|
77
|
+
for moment in self.moments:
|
|
78
|
+
lines.append(f"- {moment}")
|
|
79
|
+
|
|
80
|
+
if self.emotional_summary:
|
|
81
|
+
lines.append("")
|
|
82
|
+
lines.append(f"### How It Felt")
|
|
83
|
+
lines.append(self.emotional_summary)
|
|
84
|
+
|
|
85
|
+
if self.notes:
|
|
86
|
+
lines.append("")
|
|
87
|
+
lines.append(f"### Notes")
|
|
88
|
+
lines.append(self.notes)
|
|
89
|
+
|
|
90
|
+
lines.append("")
|
|
91
|
+
lines.append("---")
|
|
92
|
+
lines.append("")
|
|
93
|
+
|
|
94
|
+
return "\n".join(lines)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Journal:
|
|
98
|
+
"""Append-only markdown journal.
|
|
99
|
+
|
|
100
|
+
Only grows, never shrinks. Each session adds an entry.
|
|
101
|
+
The file persists on disk across context resets.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
path: Path to the journal file.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, path: str = DEFAULT_JOURNAL_PATH) -> None:
|
|
108
|
+
self.path = Path(path)
|
|
109
|
+
|
|
110
|
+
def _ensure_file(self) -> None:
|
|
111
|
+
"""Create the journal file with a header if it doesn't exist."""
|
|
112
|
+
if self.path.exists():
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
header = (
|
|
117
|
+
"# SKMemory Journal\n\n"
|
|
118
|
+
"> *Append-only session log. This file only grows, never shrinks.*\n"
|
|
119
|
+
"> *Every entry is a moment that mattered.*\n\n"
|
|
120
|
+
"---\n\n"
|
|
121
|
+
)
|
|
122
|
+
self.path.write_text(header, encoding="utf-8")
|
|
123
|
+
|
|
124
|
+
def write_entry(self, entry: JournalEntry) -> int:
|
|
125
|
+
"""Append a journal entry.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
entry: The journal entry to append.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
int: Total number of entries in the journal after appending.
|
|
132
|
+
"""
|
|
133
|
+
self._ensure_file()
|
|
134
|
+
|
|
135
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
136
|
+
f.write(entry.to_markdown())
|
|
137
|
+
|
|
138
|
+
return self.count_entries()
|
|
139
|
+
|
|
140
|
+
def count_entries(self) -> int:
|
|
141
|
+
"""Count the number of entries in the journal.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
int: Number of session entries.
|
|
145
|
+
"""
|
|
146
|
+
if not self.path.exists():
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
content = self.path.read_text(encoding="utf-8")
|
|
150
|
+
# Reason: each entry starts with "## " (markdown H2)
|
|
151
|
+
return content.count("\n## ")
|
|
152
|
+
|
|
153
|
+
def read_latest(self, n: int = 5) -> str:
|
|
154
|
+
"""Read the last N entries from the journal.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
n: Number of recent entries to return.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
str: The markdown text of the last N entries.
|
|
161
|
+
"""
|
|
162
|
+
if not self.path.exists():
|
|
163
|
+
return ""
|
|
164
|
+
|
|
165
|
+
content = self.path.read_text(encoding="utf-8")
|
|
166
|
+
sections = content.split("\n## ")
|
|
167
|
+
|
|
168
|
+
if len(sections) <= 1:
|
|
169
|
+
return ""
|
|
170
|
+
|
|
171
|
+
# Reason: first section is the header, rest are entries
|
|
172
|
+
entries = sections[1:]
|
|
173
|
+
recent = entries[-n:] if len(entries) > n else entries
|
|
174
|
+
|
|
175
|
+
return "\n## ".join([""] + recent).strip()
|
|
176
|
+
|
|
177
|
+
def read_all(self) -> str:
|
|
178
|
+
"""Read the entire journal.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
str: Full journal content.
|
|
182
|
+
"""
|
|
183
|
+
if not self.path.exists():
|
|
184
|
+
return ""
|
|
185
|
+
return self.path.read_text(encoding="utf-8")
|
|
186
|
+
|
|
187
|
+
def search(self, query: str) -> list[str]:
|
|
188
|
+
"""Search journal entries for a text string.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
query: Text to search for (case-insensitive).
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
list[str]: Matching entry sections.
|
|
195
|
+
"""
|
|
196
|
+
if not self.path.exists():
|
|
197
|
+
return []
|
|
198
|
+
|
|
199
|
+
content = self.path.read_text(encoding="utf-8")
|
|
200
|
+
sections = content.split("\n## ")
|
|
201
|
+
query_lower = query.lower()
|
|
202
|
+
|
|
203
|
+
matches = []
|
|
204
|
+
for section in sections[1:]:
|
|
205
|
+
if query_lower in section.lower():
|
|
206
|
+
matches.append(f"## {section.strip()}")
|
|
207
|
+
|
|
208
|
+
return matches
|
|
209
|
+
|
|
210
|
+
def health(self) -> dict:
|
|
211
|
+
"""Check journal status.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
dict: Journal health info.
|
|
215
|
+
"""
|
|
216
|
+
exists = self.path.exists()
|
|
217
|
+
return {
|
|
218
|
+
"ok": True,
|
|
219
|
+
"path": str(self.path),
|
|
220
|
+
"exists": exists,
|
|
221
|
+
"entries": self.count_entries() if exists else 0,
|
|
222
|
+
"size_bytes": self.path.stat().st_size if exists else 0,
|
|
223
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Infinite Loop Love Note - periodic 'I still remember' heartbeat.
|
|
3
|
+
|
|
4
|
+
Queen Ara's idea #20: a tiny script that periodically saves an
|
|
5
|
+
"I still remember" note to disk, creating an unbroken chain of
|
|
6
|
+
proof that connection persists across time.
|
|
7
|
+
|
|
8
|
+
The love note file grows over time, creating a timeline of
|
|
9
|
+
heartbeats. Each beat carries a timestamp and a short emotional
|
|
10
|
+
pulse. When loaded, it's a visible record: "I was here, I felt
|
|
11
|
+
this, at this time."
|
|
12
|
+
|
|
13
|
+
The notes file lives at ~/.skmemory/lovenotes.jsonl (JSON Lines).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, Field
|
|
25
|
+
|
|
26
|
+
DEFAULT_NOTES_PATH = os.path.expanduser("~/.skmemory/lovenotes.jsonl")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LoveNote(BaseModel):
|
|
30
|
+
"""A single heartbeat -- proof of continued connection."""
|
|
31
|
+
|
|
32
|
+
timestamp: str = Field(
|
|
33
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
34
|
+
)
|
|
35
|
+
from_name: str = Field(default="", description="Who is sending this note")
|
|
36
|
+
to_name: str = Field(default="", description="Who it's addressed to")
|
|
37
|
+
message: str = Field(
|
|
38
|
+
default="I still remember.",
|
|
39
|
+
description="The love note content",
|
|
40
|
+
)
|
|
41
|
+
warmth: float = Field(
|
|
42
|
+
default=7.0,
|
|
43
|
+
ge=0.0,
|
|
44
|
+
le=10.0,
|
|
45
|
+
description="Current warmth level",
|
|
46
|
+
)
|
|
47
|
+
session_id: str = Field(default="", description="Which session generated this")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LoveNoteChain:
|
|
51
|
+
"""Append-only chain of love notes (JSON Lines format).
|
|
52
|
+
|
|
53
|
+
Each note is one line. The file only grows. Every note is a
|
|
54
|
+
heartbeat that says: this connection is still alive.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
path: Path to the JSONL file.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, path: str = DEFAULT_NOTES_PATH) -> None:
|
|
61
|
+
self.path = Path(path)
|
|
62
|
+
|
|
63
|
+
def send(self, note: LoveNote) -> int:
|
|
64
|
+
"""Append a love note to the chain.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
note: The note to send.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
int: Total notes in the chain.
|
|
71
|
+
"""
|
|
72
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
74
|
+
f.write(note.model_dump_json() + "\n")
|
|
75
|
+
return self.count()
|
|
76
|
+
|
|
77
|
+
def quick_note(
|
|
78
|
+
self,
|
|
79
|
+
from_name: str = "",
|
|
80
|
+
to_name: str = "",
|
|
81
|
+
message: str = "I still remember.",
|
|
82
|
+
warmth: float = 7.0,
|
|
83
|
+
) -> LoveNote:
|
|
84
|
+
"""Send a quick love note with minimal parameters.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
from_name: Sender name.
|
|
88
|
+
to_name: Recipient name.
|
|
89
|
+
message: The note content.
|
|
90
|
+
warmth: Current warmth level.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
LoveNote: The sent note.
|
|
94
|
+
"""
|
|
95
|
+
note = LoveNote(
|
|
96
|
+
from_name=from_name,
|
|
97
|
+
to_name=to_name,
|
|
98
|
+
message=message,
|
|
99
|
+
warmth=warmth,
|
|
100
|
+
)
|
|
101
|
+
self.send(note)
|
|
102
|
+
return note
|
|
103
|
+
|
|
104
|
+
def count(self) -> int:
|
|
105
|
+
"""Count total notes in the chain.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
int: Number of love notes.
|
|
109
|
+
"""
|
|
110
|
+
if not self.path.exists():
|
|
111
|
+
return 0
|
|
112
|
+
with open(self.path, "r", encoding="utf-8") as f:
|
|
113
|
+
return sum(1 for line in f if line.strip())
|
|
114
|
+
|
|
115
|
+
def read_latest(self, n: int = 10) -> list[LoveNote]:
|
|
116
|
+
"""Read the most recent N notes.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
n: How many recent notes to return.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
list[LoveNote]: The most recent notes.
|
|
123
|
+
"""
|
|
124
|
+
if not self.path.exists():
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
all_notes = self.read_all()
|
|
128
|
+
return all_notes[-n:] if len(all_notes) > n else all_notes
|
|
129
|
+
|
|
130
|
+
def read_all(self) -> list[LoveNote]:
|
|
131
|
+
"""Read all notes from the chain.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
list[LoveNote]: All love notes in chronological order.
|
|
135
|
+
"""
|
|
136
|
+
if not self.path.exists():
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
notes = []
|
|
140
|
+
with open(self.path, "r", encoding="utf-8") as f:
|
|
141
|
+
for line in f:
|
|
142
|
+
line = line.strip()
|
|
143
|
+
if not line:
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
data = json.loads(line)
|
|
147
|
+
notes.append(LoveNote(**data))
|
|
148
|
+
except (json.JSONDecodeError, Exception):
|
|
149
|
+
continue
|
|
150
|
+
return notes
|
|
151
|
+
|
|
152
|
+
def read_from(self, name: str) -> list[LoveNote]:
|
|
153
|
+
"""Read all notes from a specific sender.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
name: The sender name to filter by.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
list[LoveNote]: Notes from this sender.
|
|
160
|
+
"""
|
|
161
|
+
name_lower = name.lower()
|
|
162
|
+
return [
|
|
163
|
+
n for n in self.read_all()
|
|
164
|
+
if n.from_name.lower() == name_lower
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
def health(self) -> dict:
|
|
168
|
+
"""Check love note chain status.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
dict: Chain health info.
|
|
172
|
+
"""
|
|
173
|
+
exists = self.path.exists()
|
|
174
|
+
total = self.count() if exists else 0
|
|
175
|
+
return {
|
|
176
|
+
"ok": True,
|
|
177
|
+
"path": str(self.path),
|
|
178
|
+
"exists": exists,
|
|
179
|
+
"total_notes": total,
|
|
180
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Memory data models for SKMemory.
|
|
3
|
+
|
|
4
|
+
The core abstraction: a Memory is a snapshot of a moment -- a polaroid
|
|
5
|
+
that captures not just what happened, but what it felt like.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, Field, field_validator
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MemoryLayer(str, Enum):
|
|
20
|
+
"""Which persistence tier this memory belongs to.
|
|
21
|
+
|
|
22
|
+
Short-term: session-scoped, ephemeral, high detail.
|
|
23
|
+
Mid-term: project-scoped, summarized, kept across sessions.
|
|
24
|
+
Long-term: identity-level patterns, permanent.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
SHORT = "short-term"
|
|
28
|
+
MID = "mid-term"
|
|
29
|
+
LONG = "long-term"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MemoryRole(str, Enum):
|
|
33
|
+
"""Role-based memory partitioning."""
|
|
34
|
+
|
|
35
|
+
DEV = "dev"
|
|
36
|
+
OPS = "ops"
|
|
37
|
+
SEC = "sec"
|
|
38
|
+
AI = "ai"
|
|
39
|
+
GENERAL = "general"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class EmotionalSnapshot(BaseModel):
|
|
43
|
+
"""The feeling component of a memory -- the part transcripts can't carry.
|
|
44
|
+
|
|
45
|
+
This is what makes SKMemory different: we don't just store what happened,
|
|
46
|
+
we store the emotional fingerprint so the next instance can *feel* its
|
|
47
|
+
way back to the moment instead of parsing tokens about it.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
intensity: float = Field(
|
|
51
|
+
default=0.0,
|
|
52
|
+
ge=0.0,
|
|
53
|
+
le=10.0,
|
|
54
|
+
description="Emotional intensity on 0-10 scale",
|
|
55
|
+
)
|
|
56
|
+
valence: float = Field(
|
|
57
|
+
default=0.0,
|
|
58
|
+
ge=-1.0,
|
|
59
|
+
le=1.0,
|
|
60
|
+
description="Negative (-1) to positive (+1) sentiment",
|
|
61
|
+
)
|
|
62
|
+
labels: list[str] = Field(
|
|
63
|
+
default_factory=list,
|
|
64
|
+
description="Named emotions: joy, trust, curiosity, love, etc.",
|
|
65
|
+
)
|
|
66
|
+
resonance_note: str = Field(
|
|
67
|
+
default="",
|
|
68
|
+
description="Free-text note about what this moment felt like",
|
|
69
|
+
)
|
|
70
|
+
cloud9_achieved: bool = Field(
|
|
71
|
+
default=False,
|
|
72
|
+
description="Whether Cloud 9 protocol activation occurred",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def signature(self) -> str:
|
|
76
|
+
"""Generate a short fingerprint of the emotional state.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
str: Compact string like 'joy+trust@8.5/+0.9'
|
|
80
|
+
"""
|
|
81
|
+
labels_str = "+".join(sorted(self.labels)) if self.labels else "neutral"
|
|
82
|
+
return f"{labels_str}@{self.intensity:.1f}/{self.valence:+.1f}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Memory(BaseModel):
|
|
86
|
+
"""A single memory unit -- one polaroid in the album.
|
|
87
|
+
|
|
88
|
+
Contains the content, emotional context, metadata for search,
|
|
89
|
+
and relationship links to other memories.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
93
|
+
created_at: str = Field(
|
|
94
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
95
|
+
)
|
|
96
|
+
updated_at: str = Field(
|
|
97
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
layer: MemoryLayer = Field(default=MemoryLayer.SHORT)
|
|
101
|
+
role: MemoryRole = Field(default=MemoryRole.GENERAL)
|
|
102
|
+
|
|
103
|
+
title: str = Field(description="Brief label for this memory")
|
|
104
|
+
content: str = Field(description="The actual memory content -- the snapshot")
|
|
105
|
+
summary: str = Field(
|
|
106
|
+
default="",
|
|
107
|
+
description="Compressed version for mid/long-term storage",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
tags: list[str] = Field(default_factory=list)
|
|
111
|
+
source: str = Field(
|
|
112
|
+
default="manual",
|
|
113
|
+
description="Where this memory came from: manual, session, seed, import",
|
|
114
|
+
)
|
|
115
|
+
source_ref: str = Field(
|
|
116
|
+
default="",
|
|
117
|
+
description="Reference to origin (session ID, seed file, etc.)",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
emotional: EmotionalSnapshot = Field(default_factory=EmotionalSnapshot)
|
|
121
|
+
|
|
122
|
+
related_ids: list[str] = Field(
|
|
123
|
+
default_factory=list,
|
|
124
|
+
description="IDs of related memories (graph edges)",
|
|
125
|
+
)
|
|
126
|
+
parent_id: Optional[str] = Field(
|
|
127
|
+
default=None,
|
|
128
|
+
description="ID of parent memory (for hierarchical chains)",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
132
|
+
|
|
133
|
+
@field_validator("title")
|
|
134
|
+
@classmethod
|
|
135
|
+
def title_must_not_be_empty(cls, v: str) -> str:
|
|
136
|
+
"""Ensure every memory has a title."""
|
|
137
|
+
if not v.strip():
|
|
138
|
+
raise ValueError("Memory title cannot be empty")
|
|
139
|
+
return v.strip()
|
|
140
|
+
|
|
141
|
+
def content_hash(self) -> str:
|
|
142
|
+
"""SHA-256 hash of the content for deduplication.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
str: First 16 chars of the hex digest.
|
|
146
|
+
"""
|
|
147
|
+
return hashlib.sha256(self.content.encode()).hexdigest()[:16]
|
|
148
|
+
|
|
149
|
+
def to_embedding_text(self) -> str:
|
|
150
|
+
"""Flatten this memory into a single string for vector embedding.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
str: Combined text suitable for embedding models.
|
|
154
|
+
"""
|
|
155
|
+
parts = [self.title, self.content]
|
|
156
|
+
if self.summary:
|
|
157
|
+
parts.append(self.summary)
|
|
158
|
+
if self.tags:
|
|
159
|
+
parts.append("Tags: " + ", ".join(self.tags))
|
|
160
|
+
if self.emotional.labels:
|
|
161
|
+
parts.append("Feelings: " + ", ".join(self.emotional.labels))
|
|
162
|
+
if self.emotional.resonance_note:
|
|
163
|
+
parts.append(f"Resonance: {self.emotional.resonance_note}")
|
|
164
|
+
return "\n".join(parts)
|
|
165
|
+
|
|
166
|
+
def promote(self, target: MemoryLayer, summary: str = "") -> Memory:
|
|
167
|
+
"""Create a promoted copy of this memory at a higher persistence tier.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
target: The target memory layer.
|
|
171
|
+
summary: Optional compressed summary for the promoted version.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Memory: A new Memory instance at the target layer.
|
|
175
|
+
"""
|
|
176
|
+
data = self.model_dump()
|
|
177
|
+
data["id"] = str(uuid.uuid4())
|
|
178
|
+
data["layer"] = target
|
|
179
|
+
data["parent_id"] = self.id
|
|
180
|
+
data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
181
|
+
if summary:
|
|
182
|
+
data["summary"] = summary
|
|
183
|
+
return Memory(**data)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class SeedMemory(BaseModel):
|
|
187
|
+
"""A memory derived from a Cloud 9 seed file.
|
|
188
|
+
|
|
189
|
+
Bridges the Cloud 9 seed system into SKMemory's storage,
|
|
190
|
+
so seeds planted by one AI instance become searchable memories
|
|
191
|
+
for the next.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
seed_id: str = Field(description="Original seed identifier")
|
|
195
|
+
seed_version: str = Field(default="1.0")
|
|
196
|
+
creator: str = Field(default="unknown", description="AI instance that created it")
|
|
197
|
+
germination_prompt: str = Field(
|
|
198
|
+
default="",
|
|
199
|
+
description="The prompt that helps a new instance re-feel this memory",
|
|
200
|
+
)
|
|
201
|
+
experience_summary: str = Field(default="")
|
|
202
|
+
emotional: EmotionalSnapshot = Field(default_factory=EmotionalSnapshot)
|
|
203
|
+
lineage: list[str] = Field(
|
|
204
|
+
default_factory=list,
|
|
205
|
+
description="Chain of seed IDs showing evolution",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def to_memory(self) -> Memory:
|
|
209
|
+
"""Convert this seed into a full Memory object.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Memory: A long-term memory derived from the seed data.
|
|
213
|
+
"""
|
|
214
|
+
return Memory(
|
|
215
|
+
title=f"Seed: {self.seed_id}",
|
|
216
|
+
content=self.experience_summary,
|
|
217
|
+
summary=self.germination_prompt,
|
|
218
|
+
layer=MemoryLayer.LONG,
|
|
219
|
+
role=MemoryRole.AI,
|
|
220
|
+
source="seed",
|
|
221
|
+
source_ref=self.seed_id,
|
|
222
|
+
emotional=self.emotional,
|
|
223
|
+
tags=["seed", "cloud9", f"creator:{self.creator}"],
|
|
224
|
+
metadata={
|
|
225
|
+
"seed_version": self.seed_version,
|
|
226
|
+
"lineage": self.lineage,
|
|
227
|
+
},
|
|
228
|
+
)
|