@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
package/skmemory/store.py
CHANGED
|
@@ -8,11 +8,12 @@ or by search, and the polaroid comes back with everything intact.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import logging
|
|
11
12
|
from datetime import datetime, timezone
|
|
12
|
-
from typing import Optional
|
|
13
13
|
|
|
14
14
|
from .backends.base import BaseBackend
|
|
15
15
|
from .backends.file_backend import FileBackend
|
|
16
|
+
from .backends.skgraph_backend import SKGraphBackend
|
|
16
17
|
from .backends.sqlite_backend import CONTENT_PREVIEW_LENGTH, SQLiteBackend
|
|
17
18
|
from .models import (
|
|
18
19
|
EmotionalSnapshot,
|
|
@@ -22,23 +23,35 @@ from .models import (
|
|
|
22
23
|
SeedMemory,
|
|
23
24
|
)
|
|
24
25
|
|
|
26
|
+
logger = logging.getLogger("skmemory.store")
|
|
27
|
+
|
|
28
|
+
MAX_CONTENT_LENGTH = 10000
|
|
29
|
+
CONTENT_OVERFLOW_STRATEGY = "split" # "truncate" or "split"
|
|
30
|
+
|
|
25
31
|
|
|
26
32
|
class MemoryStore:
|
|
27
33
|
"""Main entry point for all memory operations.
|
|
28
34
|
|
|
29
35
|
Delegates to one or more backends. The primary backend handles
|
|
30
36
|
all CRUD. A vector backend (optional) handles semantic search.
|
|
37
|
+
A graph backend (optional) indexes relationships for traversal.
|
|
31
38
|
|
|
32
39
|
Args:
|
|
33
40
|
primary: The primary storage backend (default: FileBackend).
|
|
34
|
-
vector: Optional vector search backend (e.g.,
|
|
41
|
+
vector: Optional vector search backend (e.g., SKVectorBackend).
|
|
42
|
+
graph: Optional graph backend (e.g., SKGraphBackend) for relationship indexing.
|
|
43
|
+
max_content_length: Max chars before overflow strategy applies (default: 10000).
|
|
44
|
+
content_overflow_strategy: "truncate" or "split" (default: "split").
|
|
35
45
|
"""
|
|
36
46
|
|
|
37
47
|
def __init__(
|
|
38
48
|
self,
|
|
39
|
-
primary:
|
|
40
|
-
vector:
|
|
49
|
+
primary: BaseBackend | None = None,
|
|
50
|
+
vector: BaseBackend | None = None,
|
|
51
|
+
graph: SKGraphBackend | None = None,
|
|
41
52
|
use_sqlite: bool = True,
|
|
53
|
+
max_content_length: int = MAX_CONTENT_LENGTH,
|
|
54
|
+
content_overflow_strategy: str = CONTENT_OVERFLOW_STRATEGY,
|
|
42
55
|
) -> None:
|
|
43
56
|
if primary is not None:
|
|
44
57
|
self.primary = primary
|
|
@@ -47,6 +60,9 @@ class MemoryStore:
|
|
|
47
60
|
else:
|
|
48
61
|
self.primary = FileBackend()
|
|
49
62
|
self.vector = vector
|
|
63
|
+
self.graph = graph
|
|
64
|
+
self.max_content_length = max_content_length
|
|
65
|
+
self.content_overflow_strategy = content_overflow_strategy
|
|
50
66
|
|
|
51
67
|
def snapshot(
|
|
52
68
|
self,
|
|
@@ -55,12 +71,12 @@ class MemoryStore:
|
|
|
55
71
|
*,
|
|
56
72
|
layer: MemoryLayer = MemoryLayer.SHORT,
|
|
57
73
|
role: MemoryRole = MemoryRole.GENERAL,
|
|
58
|
-
tags:
|
|
59
|
-
emotional:
|
|
74
|
+
tags: list[str] | None = None,
|
|
75
|
+
emotional: EmotionalSnapshot | None = None,
|
|
60
76
|
source: str = "manual",
|
|
61
77
|
source_ref: str = "",
|
|
62
|
-
related_ids:
|
|
63
|
-
metadata:
|
|
78
|
+
related_ids: list[str] | None = None,
|
|
79
|
+
metadata: dict | None = None,
|
|
64
80
|
) -> Memory:
|
|
65
81
|
"""Take a polaroid -- capture a moment as a memory.
|
|
66
82
|
|
|
@@ -82,6 +98,30 @@ class MemoryStore:
|
|
|
82
98
|
Returns:
|
|
83
99
|
Memory: The stored memory with its assigned ID.
|
|
84
100
|
"""
|
|
101
|
+
# Handle content overflow
|
|
102
|
+
if len(content) > self.max_content_length:
|
|
103
|
+
if self.content_overflow_strategy == "split":
|
|
104
|
+
return self._snapshot_split(
|
|
105
|
+
title=title,
|
|
106
|
+
content=content,
|
|
107
|
+
layer=layer,
|
|
108
|
+
role=role,
|
|
109
|
+
tags=tags,
|
|
110
|
+
emotional=emotional,
|
|
111
|
+
source=source,
|
|
112
|
+
source_ref=source_ref,
|
|
113
|
+
related_ids=related_ids,
|
|
114
|
+
metadata=metadata,
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
logger.info(
|
|
118
|
+
"Content truncated from %d to %d chars for '%s'",
|
|
119
|
+
len(content),
|
|
120
|
+
self.max_content_length,
|
|
121
|
+
title,
|
|
122
|
+
)
|
|
123
|
+
content = content[: self.max_content_length]
|
|
124
|
+
|
|
85
125
|
memory = Memory(
|
|
86
126
|
title=title,
|
|
87
127
|
content=content,
|
|
@@ -95,18 +135,122 @@ class MemoryStore:
|
|
|
95
135
|
metadata=metadata or {},
|
|
96
136
|
)
|
|
97
137
|
|
|
138
|
+
memory.seal()
|
|
139
|
+
|
|
98
140
|
self.primary.save(memory)
|
|
99
141
|
|
|
100
142
|
if self.vector:
|
|
101
143
|
try:
|
|
102
144
|
self.vector.save(memory)
|
|
103
|
-
except Exception:
|
|
104
|
-
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
logger.warning("Vector indexing failed for memory %s: %s", memory.id, exc)
|
|
147
|
+
|
|
148
|
+
if self.graph:
|
|
149
|
+
try:
|
|
150
|
+
self.graph.index_memory(memory)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
logger.warning("Graph indexing failed for memory %s: %s", memory.id, exc)
|
|
105
153
|
|
|
106
154
|
return memory
|
|
107
155
|
|
|
108
|
-
def
|
|
109
|
-
|
|
156
|
+
def _snapshot_split(
|
|
157
|
+
self,
|
|
158
|
+
title: str,
|
|
159
|
+
content: str,
|
|
160
|
+
*,
|
|
161
|
+
layer: MemoryLayer = MemoryLayer.SHORT,
|
|
162
|
+
role: MemoryRole = MemoryRole.GENERAL,
|
|
163
|
+
tags: list[str] | None = None,
|
|
164
|
+
emotional: EmotionalSnapshot | None = None,
|
|
165
|
+
source: str = "manual",
|
|
166
|
+
source_ref: str = "",
|
|
167
|
+
related_ids: list[str] | None = None,
|
|
168
|
+
metadata: dict | None = None,
|
|
169
|
+
) -> Memory:
|
|
170
|
+
"""Split oversized content into parent (summary) + child (chunk) memories.
|
|
171
|
+
|
|
172
|
+
The parent memory contains a summary (first 200 chars) and links to
|
|
173
|
+
child memories via related_ids. Each child holds one chunk.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Memory: The parent memory.
|
|
177
|
+
"""
|
|
178
|
+
chunk_size = self.max_content_length
|
|
179
|
+
chunks = [content[i : i + chunk_size] for i in range(0, len(content), chunk_size)]
|
|
180
|
+
|
|
181
|
+
logger.info(
|
|
182
|
+
"Splitting '%s' (%d chars) into %d chunks",
|
|
183
|
+
title,
|
|
184
|
+
len(content),
|
|
185
|
+
len(chunks),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Create child memories first
|
|
189
|
+
child_ids: list[str] = []
|
|
190
|
+
for i, chunk in enumerate(chunks):
|
|
191
|
+
child = Memory(
|
|
192
|
+
title=f"{title} [part {i + 1}/{len(chunks)}]",
|
|
193
|
+
content=chunk,
|
|
194
|
+
layer=layer,
|
|
195
|
+
role=role,
|
|
196
|
+
tags=(tags or []) + ["content-chunk"],
|
|
197
|
+
emotional=emotional or EmotionalSnapshot(),
|
|
198
|
+
source=source,
|
|
199
|
+
source_ref=source_ref,
|
|
200
|
+
metadata={
|
|
201
|
+
**(metadata or {}),
|
|
202
|
+
"chunk_index": i,
|
|
203
|
+
"chunk_total": len(chunks),
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
child.seal()
|
|
207
|
+
self.primary.save(child)
|
|
208
|
+
child_ids.append(child.id)
|
|
209
|
+
|
|
210
|
+
# Create parent with summary
|
|
211
|
+
summary = content[:200] + ("..." if len(content) > 200 else "")
|
|
212
|
+
all_related = (related_ids or []) + child_ids
|
|
213
|
+
|
|
214
|
+
parent = Memory(
|
|
215
|
+
title=title,
|
|
216
|
+
content=summary,
|
|
217
|
+
summary=summary,
|
|
218
|
+
layer=layer,
|
|
219
|
+
role=role,
|
|
220
|
+
tags=(tags or []) + ["content-split-parent"],
|
|
221
|
+
emotional=emotional or EmotionalSnapshot(),
|
|
222
|
+
source=source,
|
|
223
|
+
source_ref=source_ref,
|
|
224
|
+
related_ids=all_related,
|
|
225
|
+
metadata={
|
|
226
|
+
**(metadata or {}),
|
|
227
|
+
"split_children": child_ids,
|
|
228
|
+
"original_length": len(content),
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
parent.seal()
|
|
232
|
+
self.primary.save(parent)
|
|
233
|
+
|
|
234
|
+
if self.vector:
|
|
235
|
+
try:
|
|
236
|
+
self.vector.save(parent)
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
logger.warning("Vector indexing failed for split parent %s: %s", parent.id, exc)
|
|
239
|
+
|
|
240
|
+
if self.graph:
|
|
241
|
+
try:
|
|
242
|
+
self.graph.index_memory(parent)
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
logger.warning("Graph indexing failed for split parent %s: %s", parent.id, exc)
|
|
245
|
+
|
|
246
|
+
return parent
|
|
247
|
+
|
|
248
|
+
def recall(self, memory_id: str) -> Memory | None:
|
|
249
|
+
"""Retrieve a specific memory by ID with integrity verification.
|
|
250
|
+
|
|
251
|
+
Automatically checks the integrity hash on recall. If the
|
|
252
|
+
memory has been tampered with, a warning is logged and the
|
|
253
|
+
memory's metadata is flagged with 'integrity_warning'.
|
|
110
254
|
|
|
111
255
|
Args:
|
|
112
256
|
memory_id: The memory's unique identifier.
|
|
@@ -114,7 +258,22 @@ class MemoryStore:
|
|
|
114
258
|
Returns:
|
|
115
259
|
Optional[Memory]: The memory if found.
|
|
116
260
|
"""
|
|
117
|
-
|
|
261
|
+
memory = self.primary.load(memory_id)
|
|
262
|
+
if memory is None:
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
if memory.integrity_hash and not memory.verify_integrity():
|
|
266
|
+
logger.warning(
|
|
267
|
+
"TAMPER ALERT: Memory %s failed integrity check! "
|
|
268
|
+
"Content may have been modified since storage.",
|
|
269
|
+
memory_id,
|
|
270
|
+
)
|
|
271
|
+
memory.metadata["integrity_warning"] = (
|
|
272
|
+
f"Integrity check failed at {datetime.now(timezone.utc).isoformat()}. "
|
|
273
|
+
"This memory may have been tampered with."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
return memory
|
|
118
277
|
|
|
119
278
|
def search(self, query: str, limit: int = 10) -> list[Memory]:
|
|
120
279
|
"""Search memories by text.
|
|
@@ -133,8 +292,8 @@ class MemoryStore:
|
|
|
133
292
|
results = self.vector.search_text(query, limit=limit)
|
|
134
293
|
if results:
|
|
135
294
|
return results
|
|
136
|
-
except Exception:
|
|
137
|
-
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
logger.warning("Vector search failed, falling back to text search: %s", exc)
|
|
138
297
|
|
|
139
298
|
return self.primary.search_text(query, limit=limit)
|
|
140
299
|
|
|
@@ -151,14 +310,19 @@ class MemoryStore:
|
|
|
151
310
|
if self.vector:
|
|
152
311
|
try:
|
|
153
312
|
self.vector.delete(memory_id)
|
|
154
|
-
except Exception:
|
|
155
|
-
|
|
313
|
+
except Exception as exc:
|
|
314
|
+
logger.warning("Vector delete failed for memory %s: %s", memory_id, exc)
|
|
315
|
+
if self.graph:
|
|
316
|
+
try:
|
|
317
|
+
self.graph.remove_memory(memory_id)
|
|
318
|
+
except Exception as exc:
|
|
319
|
+
logger.warning("Graph delete failed for memory %s: %s", memory_id, exc)
|
|
156
320
|
return deleted
|
|
157
321
|
|
|
158
322
|
def list_memories(
|
|
159
323
|
self,
|
|
160
|
-
layer:
|
|
161
|
-
tags:
|
|
324
|
+
layer: MemoryLayer | None = None,
|
|
325
|
+
tags: list[str] | None = None,
|
|
162
326
|
limit: int = 50,
|
|
163
327
|
) -> list[Memory]:
|
|
164
328
|
"""List memories with optional filtering.
|
|
@@ -178,7 +342,7 @@ class MemoryStore:
|
|
|
178
342
|
memory_id: str,
|
|
179
343
|
target: MemoryLayer,
|
|
180
344
|
summary: str = "",
|
|
181
|
-
) ->
|
|
345
|
+
) -> Memory | None:
|
|
182
346
|
"""Promote a memory to a higher persistence tier.
|
|
183
347
|
|
|
184
348
|
Creates a new memory at the target layer linked to the original.
|
|
@@ -202,32 +366,65 @@ class MemoryStore:
|
|
|
202
366
|
if self.vector:
|
|
203
367
|
try:
|
|
204
368
|
self.vector.save(promoted)
|
|
205
|
-
except Exception:
|
|
206
|
-
|
|
369
|
+
except Exception as exc:
|
|
370
|
+
logger.warning(
|
|
371
|
+
"Vector indexing failed for promoted memory %s: %s", promoted.id, exc
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if self.graph:
|
|
375
|
+
try:
|
|
376
|
+
self.graph.index_memory(promoted)
|
|
377
|
+
except Exception as exc:
|
|
378
|
+
logger.warning(
|
|
379
|
+
"Graph indexing failed for promoted memory %s: %s", promoted.id, exc
|
|
380
|
+
)
|
|
207
381
|
|
|
208
382
|
return promoted
|
|
209
383
|
|
|
210
|
-
def ingest_seed(self, seed: SeedMemory) -> Memory:
|
|
384
|
+
def ingest_seed(self, seed: SeedMemory, *, validate: bool = True) -> Memory:
|
|
211
385
|
"""Import a Cloud 9 seed as a long-term memory.
|
|
212
386
|
|
|
213
387
|
Converts a seed into a Memory and stores it. This is how
|
|
214
388
|
seeds planted by one AI instance become retrievable memories
|
|
215
389
|
for the next.
|
|
216
390
|
|
|
391
|
+
When *validate* is True (default), basic integrity checks run
|
|
392
|
+
before storage: seed_id must be non-empty and
|
|
393
|
+
experience_summary must contain content.
|
|
394
|
+
|
|
217
395
|
Args:
|
|
218
396
|
seed: The SeedMemory to import.
|
|
397
|
+
validate: Run pre-import validation (default True).
|
|
219
398
|
|
|
220
399
|
Returns:
|
|
221
400
|
Memory: The created long-term memory.
|
|
401
|
+
|
|
402
|
+
Raises:
|
|
403
|
+
ValueError: If validation is enabled and the seed is invalid.
|
|
222
404
|
"""
|
|
405
|
+
if validate:
|
|
406
|
+
errors: list[str] = []
|
|
407
|
+
if not seed.seed_id or not seed.seed_id.strip():
|
|
408
|
+
errors.append("seed_id is empty")
|
|
409
|
+
if not seed.experience_summary or not seed.experience_summary.strip():
|
|
410
|
+
errors.append("experience_summary is empty")
|
|
411
|
+
if errors:
|
|
412
|
+
raise ValueError(f"Seed validation failed: {'; '.join(errors)}")
|
|
413
|
+
|
|
223
414
|
memory = seed.to_memory()
|
|
224
415
|
self.primary.save(memory)
|
|
225
416
|
|
|
226
417
|
if self.vector:
|
|
227
418
|
try:
|
|
228
419
|
self.vector.save(memory)
|
|
229
|
-
except Exception:
|
|
230
|
-
|
|
420
|
+
except Exception as exc:
|
|
421
|
+
logger.warning("Vector indexing failed for seed memory %s: %s", memory.id, exc)
|
|
422
|
+
|
|
423
|
+
if self.graph:
|
|
424
|
+
try:
|
|
425
|
+
self.graph.index_memory(memory)
|
|
426
|
+
except Exception as exc:
|
|
427
|
+
logger.warning("Graph indexing failed for seed memory %s: %s", memory.id, exc)
|
|
231
428
|
|
|
232
429
|
return memory
|
|
233
430
|
|
|
@@ -249,7 +446,7 @@ class MemoryStore:
|
|
|
249
446
|
self,
|
|
250
447
|
session_id: str,
|
|
251
448
|
summary: str,
|
|
252
|
-
emotional:
|
|
449
|
+
emotional: EmotionalSnapshot | None = None,
|
|
253
450
|
) -> Memory:
|
|
254
451
|
"""Compress a session's short-term memories into a single mid-term memory.
|
|
255
452
|
|
|
@@ -291,89 +488,156 @@ class MemoryStore:
|
|
|
291
488
|
|
|
292
489
|
def load_context(
|
|
293
490
|
self,
|
|
294
|
-
max_tokens: int =
|
|
491
|
+
max_tokens: int = 4000,
|
|
295
492
|
strongest_count: int = 5,
|
|
296
493
|
recent_count: int = 5,
|
|
297
494
|
include_seeds: bool = True,
|
|
298
495
|
) -> dict:
|
|
299
|
-
"""Load
|
|
496
|
+
"""Load tiered memory context for agent injection (lazy loading).
|
|
300
497
|
|
|
301
|
-
Uses
|
|
302
|
-
|
|
498
|
+
Uses date-based tiers per memory-architecture.md:
|
|
499
|
+
- Today's memories: full content (title + body)
|
|
500
|
+
- Yesterday's memories: summary only (title + first 2 sentences)
|
|
501
|
+
- Older than 2 days: reference count only
|
|
303
502
|
|
|
304
503
|
Args:
|
|
305
|
-
max_tokens: Approximate token budget (
|
|
504
|
+
max_tokens: Approximate token budget (default: 4000).
|
|
505
|
+
Uses word_count * 1.3 approximation for estimation.
|
|
306
506
|
strongest_count: How many top-intensity memories to include.
|
|
307
507
|
recent_count: How many recent memories to include.
|
|
308
508
|
include_seeds: Whether to include seed memories.
|
|
309
509
|
|
|
310
510
|
Returns:
|
|
311
|
-
dict: Token-efficient context with
|
|
511
|
+
dict: Token-efficient tiered context with metadata.
|
|
312
512
|
"""
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
513
|
+
context: dict = {
|
|
514
|
+
"today": [],
|
|
515
|
+
"yesterday": [],
|
|
516
|
+
"older_summary": {},
|
|
517
|
+
"seeds": [],
|
|
518
|
+
"stats": {},
|
|
519
|
+
}
|
|
520
|
+
used_tokens = 0
|
|
316
521
|
|
|
317
522
|
if isinstance(self.primary, SQLiteBackend):
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
523
|
+
conn = self.primary._get_conn()
|
|
524
|
+
|
|
525
|
+
# --- Tier 1: Today's memories (full content) ---
|
|
526
|
+
today_rows = conn.execute(
|
|
527
|
+
"SELECT * FROM memories WHERE DATE(created_at) = DATE('now') "
|
|
528
|
+
"ORDER BY importance DESC, created_at DESC LIMIT 20"
|
|
529
|
+
).fetchall()
|
|
530
|
+
|
|
531
|
+
for row in today_rows:
|
|
532
|
+
summary_dict = self.primary._row_to_memory_summary(row)
|
|
533
|
+
# Include full content for today
|
|
534
|
+
content = summary_dict.get("summary") or summary_dict.get("content_preview") or ""
|
|
535
|
+
entry = {
|
|
536
|
+
"id": summary_dict["id"],
|
|
537
|
+
"title": summary_dict["title"],
|
|
538
|
+
"content": content,
|
|
539
|
+
"tags": summary_dict["tags"],
|
|
540
|
+
"layer": summary_dict["layer"],
|
|
541
|
+
"emotional_intensity": summary_dict["emotional_intensity"],
|
|
542
|
+
}
|
|
543
|
+
entry_tokens = _estimate_tokens(entry["title"] + " " + content)
|
|
544
|
+
if used_tokens + entry_tokens > max_tokens:
|
|
337
545
|
break
|
|
338
|
-
|
|
339
|
-
context["
|
|
340
|
-
|
|
546
|
+
used_tokens += entry_tokens
|
|
547
|
+
context["today"].append(entry)
|
|
548
|
+
|
|
549
|
+
# --- Tier 2: Yesterday's memories (summary only: title + first 2 sentences) ---
|
|
550
|
+
yesterday_rows = conn.execute(
|
|
551
|
+
"SELECT * FROM memories WHERE DATE(created_at) = DATE('now', '-1 day') "
|
|
552
|
+
"ORDER BY importance DESC, created_at DESC LIMIT 20"
|
|
553
|
+
).fetchall()
|
|
554
|
+
|
|
555
|
+
for row in yesterday_rows:
|
|
556
|
+
summary_dict = self.primary._row_to_memory_summary(row)
|
|
557
|
+
raw_text = summary_dict.get("summary") or summary_dict.get("content_preview") or ""
|
|
558
|
+
short_summary = _first_n_sentences(raw_text, 2)
|
|
559
|
+
entry = {
|
|
560
|
+
"id": summary_dict["id"],
|
|
561
|
+
"title": summary_dict["title"],
|
|
562
|
+
"summary": short_summary,
|
|
563
|
+
}
|
|
564
|
+
entry_tokens = _estimate_tokens(entry["title"] + " " + short_summary)
|
|
565
|
+
if used_tokens + entry_tokens > max_tokens:
|
|
566
|
+
break
|
|
567
|
+
used_tokens += entry_tokens
|
|
568
|
+
context["yesterday"].append(entry)
|
|
569
|
+
|
|
570
|
+
# --- Tier 3: Older memories (reference count only) ---
|
|
571
|
+
mid_count = conn.execute(
|
|
572
|
+
"SELECT COUNT(*) FROM memories WHERE DATE(created_at) < DATE('now', '-1 day') "
|
|
573
|
+
"AND layer = 'mid-term'"
|
|
574
|
+
).fetchone()[0]
|
|
575
|
+
long_count = conn.execute(
|
|
576
|
+
"SELECT COUNT(*) FROM memories WHERE DATE(created_at) < DATE('now', '-1 day') "
|
|
577
|
+
"AND layer = 'long-term'"
|
|
578
|
+
).fetchone()[0]
|
|
579
|
+
short_old_count = conn.execute(
|
|
580
|
+
"SELECT COUNT(*) FROM memories WHERE DATE(created_at) < DATE('now', '-1 day') "
|
|
581
|
+
"AND layer = 'short-term'"
|
|
582
|
+
).fetchone()[0]
|
|
583
|
+
|
|
584
|
+
context["older_summary"] = {
|
|
585
|
+
"mid_term_count": mid_count,
|
|
586
|
+
"long_term_count": long_count,
|
|
587
|
+
"short_term_count": short_old_count,
|
|
588
|
+
"total": mid_count + long_count + short_old_count,
|
|
589
|
+
"hint": (
|
|
590
|
+
f"{mid_count} mid-term memories, {long_count} long-term memories "
|
|
591
|
+
"available via memory_search"
|
|
592
|
+
),
|
|
593
|
+
}
|
|
594
|
+
used_tokens += _estimate_tokens(context["older_summary"]["hint"])
|
|
595
|
+
|
|
596
|
+
# --- Seeds (titles only to save tokens) ---
|
|
341
597
|
if include_seeds:
|
|
342
|
-
|
|
598
|
+
seed_rows = self.primary.list_summaries(
|
|
343
599
|
tags=["seed"],
|
|
344
600
|
limit=10,
|
|
345
601
|
order_by="emotional_intensity",
|
|
346
602
|
)
|
|
347
|
-
for
|
|
603
|
+
seen_ids = {m["id"] for m in context["today"]}
|
|
604
|
+
seen_ids.update(m["id"] for m in context["yesterday"])
|
|
605
|
+
|
|
606
|
+
for seed in seed_rows:
|
|
348
607
|
if seed["id"] in seen_ids:
|
|
349
608
|
continue
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
609
|
+
entry = {
|
|
610
|
+
"id": seed["id"],
|
|
611
|
+
"title": seed["title"],
|
|
612
|
+
}
|
|
613
|
+
entry_tokens = _estimate_tokens(seed["title"])
|
|
614
|
+
if used_tokens + entry_tokens > max_tokens:
|
|
353
615
|
break
|
|
354
|
-
|
|
355
|
-
context["seeds"].append(
|
|
616
|
+
used_tokens += entry_tokens
|
|
617
|
+
context["seeds"].append(entry)
|
|
356
618
|
|
|
357
619
|
stats = self.primary.stats()
|
|
358
620
|
context["stats"] = stats
|
|
359
621
|
else:
|
|
360
|
-
#
|
|
622
|
+
# Fallback for non-SQLite backends: simple recent list
|
|
361
623
|
all_mems = self.primary.list_memories(limit=strongest_count + recent_count)
|
|
362
624
|
for mem in all_mems:
|
|
625
|
+
content_text = mem.summary or mem.content[:CONTENT_PREVIEW_LENGTH]
|
|
363
626
|
entry = {
|
|
364
627
|
"id": mem.id,
|
|
365
628
|
"title": mem.title,
|
|
366
|
-
"summary":
|
|
629
|
+
"summary": _first_n_sentences(content_text, 2),
|
|
367
630
|
"emotional_intensity": mem.emotional.intensity,
|
|
368
631
|
"layer": mem.layer.value,
|
|
369
632
|
}
|
|
370
|
-
|
|
371
|
-
if
|
|
633
|
+
entry_tokens = _estimate_tokens(entry["title"] + " " + entry["summary"])
|
|
634
|
+
if used_tokens + entry_tokens > max_tokens:
|
|
372
635
|
break
|
|
373
|
-
|
|
374
|
-
context["
|
|
636
|
+
used_tokens += entry_tokens
|
|
637
|
+
context["today"].append(entry)
|
|
375
638
|
|
|
376
|
-
context["token_estimate"] =
|
|
639
|
+
context["token_estimate"] = used_tokens
|
|
640
|
+
context["token_budget"] = max_tokens
|
|
377
641
|
return context
|
|
378
642
|
|
|
379
643
|
def export_backup(self, output_path: str | None = None) -> str:
|
|
@@ -381,7 +645,7 @@ class MemoryStore:
|
|
|
381
645
|
|
|
382
646
|
Args:
|
|
383
647
|
output_path: Destination file. Defaults to
|
|
384
|
-
``~/.
|
|
648
|
+
``~/.skcapstone/backups/skmemory-backup-YYYY-MM-DD.json``.
|
|
385
649
|
|
|
386
650
|
Returns:
|
|
387
651
|
str: Path to the written backup file.
|
|
@@ -396,9 +660,7 @@ class MemoryStore:
|
|
|
396
660
|
temp = SQLiteBackend(base_path=str(self.primary.base_path))
|
|
397
661
|
temp.reindex()
|
|
398
662
|
return temp.export_all(output_path)
|
|
399
|
-
raise RuntimeError(
|
|
400
|
-
f"Export not supported for backend: {type(self.primary).__name__}"
|
|
401
|
-
)
|
|
663
|
+
raise RuntimeError(f"Export not supported for backend: {type(self.primary).__name__}")
|
|
402
664
|
|
|
403
665
|
def import_backup(self, backup_path: str) -> int:
|
|
404
666
|
"""Restore memories from a JSON backup file.
|
|
@@ -414,9 +676,37 @@ class MemoryStore:
|
|
|
414
676
|
"""
|
|
415
677
|
if isinstance(self.primary, SQLiteBackend):
|
|
416
678
|
return self.primary.import_backup(backup_path)
|
|
417
|
-
raise RuntimeError(
|
|
418
|
-
|
|
419
|
-
|
|
679
|
+
raise RuntimeError(f"Import not supported for backend: {type(self.primary).__name__}")
|
|
680
|
+
|
|
681
|
+
def list_backups(self, backup_dir: str | None = None) -> list[dict]:
|
|
682
|
+
"""List all skmemory backup files, sorted newest first.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
backup_dir: Directory to scan. Defaults to
|
|
686
|
+
``~/.skcapstone/backups/``.
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
list[dict]: Backup entries with ``path``, ``name``,
|
|
690
|
+
``size_bytes``, and ``date`` keys.
|
|
691
|
+
"""
|
|
692
|
+
if isinstance(self.primary, SQLiteBackend):
|
|
693
|
+
return self.primary.list_backups(backup_dir)
|
|
694
|
+
return []
|
|
695
|
+
|
|
696
|
+
def prune_backups(self, keep: int = 7, backup_dir: str | None = None) -> list[str]:
|
|
697
|
+
"""Delete oldest backups, keeping only the N most recent.
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
keep: Number of backups to retain (default: 7).
|
|
701
|
+
backup_dir: Directory to prune. Defaults to
|
|
702
|
+
``~/.skcapstone/backups/``.
|
|
703
|
+
|
|
704
|
+
Returns:
|
|
705
|
+
list[str]: Paths of deleted backup files.
|
|
706
|
+
"""
|
|
707
|
+
if isinstance(self.primary, SQLiteBackend):
|
|
708
|
+
return self.primary.prune_backups(keep=keep, backup_dir=backup_dir)
|
|
709
|
+
return []
|
|
420
710
|
|
|
421
711
|
def reindex(self) -> int:
|
|
422
712
|
"""Rebuild the SQLite index from JSON files.
|
|
@@ -442,4 +732,47 @@ class MemoryStore:
|
|
|
442
732
|
status["vector"] = self.vector.health_check()
|
|
443
733
|
except Exception as e:
|
|
444
734
|
status["vector"] = {"ok": False, "error": str(e)}
|
|
735
|
+
if self.graph:
|
|
736
|
+
try:
|
|
737
|
+
status["graph"] = self.graph.health_check()
|
|
738
|
+
except Exception as e:
|
|
739
|
+
status["graph"] = {"ok": False, "error": str(e)}
|
|
445
740
|
return status
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _estimate_tokens(text: str) -> int:
|
|
744
|
+
"""Estimate token count using word_count * 1.3 approximation.
|
|
745
|
+
|
|
746
|
+
Args:
|
|
747
|
+
text: The text to estimate.
|
|
748
|
+
|
|
749
|
+
Returns:
|
|
750
|
+
int: Approximate token count.
|
|
751
|
+
"""
|
|
752
|
+
if not text:
|
|
753
|
+
return 0
|
|
754
|
+
word_count = len(text.split())
|
|
755
|
+
return int(word_count * 1.3)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _first_n_sentences(text: str, n: int = 2) -> str:
|
|
759
|
+
"""Extract the first N sentences from text.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
text: Source text.
|
|
763
|
+
n: Number of sentences to extract.
|
|
764
|
+
|
|
765
|
+
Returns:
|
|
766
|
+
str: The first N sentences, or the full text if fewer exist.
|
|
767
|
+
"""
|
|
768
|
+
if not text:
|
|
769
|
+
return ""
|
|
770
|
+
# Split on sentence-ending punctuation followed by whitespace
|
|
771
|
+
import re
|
|
772
|
+
|
|
773
|
+
sentences = re.split(r"(?<=[.!?])\s+", text.strip())
|
|
774
|
+
result = " ".join(sentences[:n])
|
|
775
|
+
# Cap at 200 chars as a safety net
|
|
776
|
+
if len(result) > 200:
|
|
777
|
+
result = result[:197] + "..."
|
|
778
|
+
return result
|