superlocalmemory 3.6.13 → 3.6.15
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/.claude-plugin/marketplace.json +17 -0
- package/CHANGELOG.md +28 -0
- package/README.md +189 -740
- package/package.json +12 -5
- package/plugin/.claude-plugin/plugin.json +20 -0
- package/plugin/.mcp.json +12 -0
- package/plugin/CLAUDE.md +44 -0
- package/plugin/_GENERATED.md +6 -0
- package/plugin/agents/slm-memory-advisor.md +44 -0
- package/plugin/agents/slm-optimize-advisor.md +38 -0
- package/plugin/hooks/hooks.json +14 -0
- package/plugin/requirements.txt +1 -0
- package/plugin/scripts/ensure-venv.bat +122 -0
- package/plugin/scripts/ensure-venv.sh +105 -0
- package/plugin/scripts/slm-launch +15 -0
- package/plugin/scripts/slm-launch.bat +17 -0
- package/plugin/settings.json +16 -0
- package/plugin/skills/slm-cache/SKILL.md +140 -0
- package/plugin/skills/slm-compress/SKILL.md +143 -0
- package/plugin/skills/slm-graph/SKILL.md +300 -0
- package/plugin/skills/slm-recall/SKILL.md +204 -0
- package/plugin/skills/slm-remember/SKILL.md +194 -0
- package/plugin/skills/slm-session/SKILL.md +207 -0
- package/plugin/skills/slm-status/SKILL.md +149 -0
- package/plugin-src/.mcp.json +12 -0
- package/plugin-src/agents/slm-memory-advisor.md +44 -0
- package/plugin-src/agents/slm-optimize-advisor.md +38 -0
- package/plugin-src/commands/slm-optimize.md +22 -0
- package/plugin-src/commands/slm-recall.md +16 -0
- package/plugin-src/commands/slm-remember.md +16 -0
- package/plugin-src/commands/slm-status.md +15 -0
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +14 -0
- package/plugin-src/manifest.json +25 -0
- package/plugin-src/requirements.txt +1 -0
- package/plugin-src/rules/AGENTS.md +91 -0
- package/plugin-src/rules/CLAUDE.md.fragment +44 -0
- package/plugin-src/scripts/ensure-venv.bat +122 -0
- package/plugin-src/scripts/ensure-venv.sh +105 -0
- package/plugin-src/scripts/slm-launch +15 -0
- package/plugin-src/scripts/slm-launch.bat +17 -0
- package/plugin-src/settings.json +16 -0
- package/plugin-src/skills/slm-cache/SKILL.md +140 -0
- package/plugin-src/skills/slm-compress/SKILL.md +143 -0
- package/plugin-src/skills/slm-graph/SKILL.md +300 -0
- package/plugin-src/skills/slm-recall/SKILL.md +204 -0
- package/plugin-src/skills/slm-remember/SKILL.md +194 -0
- package/plugin-src/skills/slm-session/SKILL.md +207 -0
- package/plugin-src/skills/slm-status/SKILL.md +149 -0
- package/pyproject.toml +6 -2
- package/scripts/__tests__/build-plugin.test.mjs +613 -0
- package/scripts/_savings_math.py +270 -0
- package/scripts/build-plugin.js +742 -0
- package/scripts/dogfood_savings.py +490 -0
- package/scripts/install-skills.ps1 +4 -334
- package/scripts/install-skills.sh +4 -435
- package/scripts/postinstall-interactive.js +0 -27
- package/scripts/postinstall.js +21 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/_lazy_init.py +115 -0
- package/src/superlocalmemory/cli/commands.py +439 -41
- package/src/superlocalmemory/cli/main.py +92 -4
- package/src/superlocalmemory/cli/setup_wizard.py +47 -6
- package/src/superlocalmemory/core/backend_orchestrator.py +12 -8
- package/src/superlocalmemory/core/config.py +194 -9
- package/src/superlocalmemory/core/embeddings.py +10 -5
- package/src/superlocalmemory/core/engine.py +76 -5
- package/src/superlocalmemory/core/fact_consolidator.py +20 -3
- package/src/superlocalmemory/core/platform_utils.py +8 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -0
- package/src/superlocalmemory/core/recall_worker.py +7 -0
- package/src/superlocalmemory/core/store_pipeline.py +23 -1
- package/src/superlocalmemory/core/worker_pool.py +14 -2
- package/src/superlocalmemory/hooks/claude_code_hooks.py +27 -3
- package/src/superlocalmemory/hooks/portable_kit.py +506 -0
- package/src/superlocalmemory/hooks/session_registry.py +8 -4
- package/src/superlocalmemory/infra/cloud_backup.py +99 -23
- package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -2
- package/src/superlocalmemory/mcp/_pool_adapter.py +15 -6
- package/src/superlocalmemory/mcp/cli_fallback.py +602 -0
- package/src/superlocalmemory/mcp/server.py +75 -4
- package/src/superlocalmemory/mcp/tools_code_graph.py +3 -3
- package/src/superlocalmemory/mcp/tools_core.py +37 -4
- package/src/superlocalmemory/mcp/tools_v3.py +6 -1
- package/src/superlocalmemory/mcp/tools_v33.py +8 -4
- package/src/superlocalmemory/optimize/cache/boundary_store.py +25 -6
- package/src/superlocalmemory/optimize/cache/centroid_store.py +27 -4
- package/src/superlocalmemory/optimize/cache/manager.py +92 -6
- package/src/superlocalmemory/optimize/cache/semantic.py +20 -1
- package/src/superlocalmemory/optimize/compress/ccr.py +12 -0
- package/src/superlocalmemory/optimize/compress/router.py +46 -13
- package/src/superlocalmemory/optimize/config/schema.py +6 -0
- package/src/superlocalmemory/optimize/proxy/_helpers.py +111 -8
- package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +14 -4
- package/src/superlocalmemory/optimize/proxy/gemini_surface.py +23 -6
- package/src/superlocalmemory/optimize/proxy/openai_surface.py +10 -4
- package/src/superlocalmemory/optimize/proxy/server.py +11 -0
- package/src/superlocalmemory/optimize/proxy/vertex_surface.py +246 -0
- package/src/superlocalmemory/optimize/storage/db.py +30 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +12 -2
- package/src/superlocalmemory/retrieval/engine.py +36 -3
- package/src/superlocalmemory/retrieval/entity_channel.py +5 -5
- package/src/superlocalmemory/retrieval/hopfield_channel.py +10 -2
- package/src/superlocalmemory/retrieval/semantic_channel.py +10 -2
- package/src/superlocalmemory/server/recall_serializer.py +3 -1
- package/src/superlocalmemory/server/unified_daemon.py +156 -16
- package/src/superlocalmemory/storage/database.py +215 -43
- package/src/superlocalmemory/storage/migration_runner.py +17 -1
- package/src/superlocalmemory/storage/migrations/M016_add_scope_support.py +120 -0
- package/src/superlocalmemory/storage/models.py +10 -0
- package/src/superlocalmemory/storage/schema.py +15 -10
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +18 -0
- package/src/superlocalmemory/ui/css/neural-glass.css +5 -0
- package/src/superlocalmemory/ui/index.html +2 -2
- package/src/superlocalmemory/ui/js/core.js +98 -0
- package/src/superlocalmemory/ui/js/dashboard.js +8 -1
- package/src/superlocalmemory/ui/js/ide-status.js +16 -3
- package/src/superlocalmemory/ui/js/math-health.js +15 -3
- package/src/superlocalmemory/ui/js/optimize.js +18 -2
- package/src/superlocalmemory/ui/js/trust-dashboard.js +10 -1
- package/src/superlocalmemory.egg-info/PKG-INFO +191 -741
- package/src/superlocalmemory.egg-info/SOURCES.txt +7 -9
- package/src/superlocalmemory.egg-info/requires.txt +1 -0
- package/ide/skills/slm-build-graph/SKILL.md +0 -423
- package/ide/skills/slm-list-recent/SKILL.md +0 -348
- package/ide/skills/slm-recall/SKILL.md +0 -326
- package/ide/skills/slm-remember/SKILL.md +0 -194
- package/ide/skills/slm-show-patterns/SKILL.md +0 -224
- package/ide/skills/slm-status/SKILL.md +0 -363
- package/ide/skills/slm-switch-profile/SKILL.md +0 -442
- package/skills/slm-build-graph/SKILL.md +0 -423
- package/skills/slm-list-recent/SKILL.md +0 -348
- package/skills/slm-optimize/README.md +0 -55
- package/skills/slm-optimize/SKILL.md +0 -139
- package/skills/slm-recall/SKILL.md +0 -343
- package/skills/slm-remember/SKILL.md +0 -194
- package/skills/slm-show-patterns/SKILL.md +0 -224
- package/skills/slm-status/SKILL.md +0 -363
- package/skills/slm-switch-profile/SKILL.md +0 -442
- package/src/superlocalmemory/cli/doctor_cmd.py +0 -152
- package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +0 -423
- package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +0 -348
- package/src/superlocalmemory/skills/slm-recall/SKILL.md +0 -343
- package/src/superlocalmemory/skills/slm-remember/SKILL.md +0 -194
- package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +0 -224
- package/src/superlocalmemory/skills/slm-status/SKILL.md +0 -363
- package/src/superlocalmemory/skills/slm-switch-profile/SKILL.md +0 -442
|
@@ -43,6 +43,49 @@ _MAX_RETRIES = 5 # retry on transient SQLITE_BUSY
|
|
|
43
43
|
_RETRY_BASE_DELAY = 0.1 # seconds — exponential backoff base
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def _scope_where(
|
|
47
|
+
profile_id: str,
|
|
48
|
+
*,
|
|
49
|
+
include_global: bool = False,
|
|
50
|
+
include_shared: bool = False,
|
|
51
|
+
prefix: str = "",
|
|
52
|
+
) -> tuple[str, list]:
|
|
53
|
+
"""Build scope-filtering WHERE clause for multi-scope retrieval.
|
|
54
|
+
|
|
55
|
+
Returns ``(where_clause, params)`` for splicing into SQL queries.
|
|
56
|
+
|
|
57
|
+
When ``include_global=True``, facts with ``scope='global'`` are included
|
|
58
|
+
regardless of profile. When ``include_shared=True``, facts explicitly
|
|
59
|
+
shared with this profile (via ``shared_with`` JSON array) are also
|
|
60
|
+
included.
|
|
61
|
+
|
|
62
|
+
v3.6.15: defaults are SHARED-OFF (include_global/include_shared=False) so
|
|
63
|
+
any DIRECT caller (search, list_recent, fetch, resources) is private by
|
|
64
|
+
default — shared memory is opt-in. The recall channels pass explicit
|
|
65
|
+
resolved flags, so opt-in recall is unaffected. With both False the clause
|
|
66
|
+
collapses to ``profile_id = ?`` — identical to 3.6.14 isolation.
|
|
67
|
+
"""
|
|
68
|
+
table = f"{prefix}." if prefix else ""
|
|
69
|
+
clauses = [f"({table}profile_id = ?)"]
|
|
70
|
+
params: list = [profile_id]
|
|
71
|
+
|
|
72
|
+
if include_global:
|
|
73
|
+
clauses.append(f"({table}scope = 'global')")
|
|
74
|
+
|
|
75
|
+
if include_shared:
|
|
76
|
+
# Match the profile_id as a quoted JSON-array element. ESCAPE the LIKE
|
|
77
|
+
# metacharacters in profile_id so a profile id containing % or _ cannot
|
|
78
|
+
# false-positive-match another profile's shared_with list.
|
|
79
|
+
_esc = profile_id.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
80
|
+
clauses.append(
|
|
81
|
+
f"({table}scope = 'shared' AND {table}shared_with LIKE ? ESCAPE '\\')"
|
|
82
|
+
)
|
|
83
|
+
params.append(f'%"{_esc}"%')
|
|
84
|
+
|
|
85
|
+
where = "(" + " OR ".join(clauses) + ")"
|
|
86
|
+
return where, params
|
|
87
|
+
|
|
88
|
+
|
|
46
89
|
class DatabaseManager:
|
|
47
90
|
"""Concurrent-safe SQLite manager with WAL, profile isolation, and FTS5.
|
|
48
91
|
|
|
@@ -115,6 +158,28 @@ class DatabaseManager:
|
|
|
115
158
|
self._txn_conn = None
|
|
116
159
|
conn.close()
|
|
117
160
|
|
|
161
|
+
@contextmanager
|
|
162
|
+
def raw_connection(self) -> Generator[sqlite3.Connection, None, None]:
|
|
163
|
+
"""Yield a live sqlite3.Connection for code that needs one directly.
|
|
164
|
+
|
|
165
|
+
For callers (e.g. schema migrations) that must hold a real connection
|
|
166
|
+
rather than going through execute(). Commits on success, rolls back on
|
|
167
|
+
error, and always closes — mirroring transaction(). This is the public
|
|
168
|
+
way to obtain a connection; there is no `.conn` attribute.
|
|
169
|
+
"""
|
|
170
|
+
with self._lock:
|
|
171
|
+
conn = self._connect()
|
|
172
|
+
self._txn_conn = conn
|
|
173
|
+
try:
|
|
174
|
+
yield conn
|
|
175
|
+
conn.commit()
|
|
176
|
+
except Exception:
|
|
177
|
+
conn.rollback()
|
|
178
|
+
raise
|
|
179
|
+
finally:
|
|
180
|
+
self._txn_conn = None
|
|
181
|
+
conn.close()
|
|
182
|
+
|
|
118
183
|
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
|
|
119
184
|
"""Execute SQL with automatic retry on SQLITE_BUSY.
|
|
120
185
|
|
|
@@ -149,15 +214,18 @@ class DatabaseManager:
|
|
|
149
214
|
|
|
150
215
|
def store_memory(self, record: MemoryRecord) -> str:
|
|
151
216
|
"""Persist a raw memory record. Returns memory_id."""
|
|
217
|
+
_scope = getattr(record, 'scope', None) or 'personal'
|
|
218
|
+
_shared = _jd(getattr(record, 'shared_with', None))
|
|
152
219
|
self.execute(
|
|
153
220
|
"""INSERT OR REPLACE INTO memories
|
|
154
221
|
(memory_id, profile_id, content, session_id, speaker,
|
|
155
|
-
role, session_date, created_at, metadata_json
|
|
156
|
-
|
|
222
|
+
role, session_date, created_at, metadata_json,
|
|
223
|
+
scope, shared_with)
|
|
224
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
|
157
225
|
(record.memory_id, record.profile_id, record.content,
|
|
158
226
|
record.session_id, record.speaker, record.role,
|
|
159
227
|
record.session_date, record.created_at,
|
|
160
|
-
json.dumps(record.metadata)),
|
|
228
|
+
json.dumps(record.metadata), _scope, _shared),
|
|
161
229
|
)
|
|
162
230
|
return record.memory_id
|
|
163
231
|
|
|
@@ -229,6 +297,8 @@ class DatabaseManager:
|
|
|
229
297
|
# orphaned id that was never inserted.
|
|
230
298
|
fact.fact_id = canonical_id
|
|
231
299
|
return canonical_id
|
|
300
|
+
_scope = getattr(fact, 'scope', None) or 'personal'
|
|
301
|
+
_shared = _jd(getattr(fact, 'shared_with', None))
|
|
232
302
|
self.execute(
|
|
233
303
|
"""INSERT OR REPLACE INTO atomic_facts
|
|
234
304
|
(fact_id, memory_id, profile_id, content, fact_type,
|
|
@@ -238,8 +308,9 @@ class DatabaseManager:
|
|
|
238
308
|
source_turn_ids_json, session_id,
|
|
239
309
|
embedding, fisher_mean, fisher_variance,
|
|
240
310
|
lifecycle, langevin_position,
|
|
241
|
-
emotional_valence, emotional_arousal, signal_type, created_at
|
|
242
|
-
|
|
311
|
+
emotional_valence, emotional_arousal, signal_type, created_at,
|
|
312
|
+
scope, shared_with)
|
|
313
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
243
314
|
(fact.fact_id, fact.memory_id, fact.profile_id, fact.content,
|
|
244
315
|
fact.fact_type.value,
|
|
245
316
|
json.dumps(fact.entities), json.dumps(fact.canonical_entities),
|
|
@@ -250,7 +321,7 @@ class DatabaseManager:
|
|
|
250
321
|
_jd(fact.embedding), _jd(fact.fisher_mean), _jd(fact.fisher_variance),
|
|
251
322
|
fact.lifecycle.value, _jd(fact.langevin_position),
|
|
252
323
|
fact.emotional_valence, fact.emotional_arousal,
|
|
253
|
-
fact.signal_type.value, fact.created_at),
|
|
324
|
+
fact.signal_type.value, fact.created_at, _scope, _shared),
|
|
254
325
|
)
|
|
255
326
|
return fact.fact_id
|
|
256
327
|
|
|
@@ -280,6 +351,8 @@ class DatabaseManager:
|
|
|
280
351
|
emotional_arousal=d.get("emotional_arousal", 0.0),
|
|
281
352
|
signal_type=SignalType(d["signal_type"]) if d.get("signal_type") else SignalType.FACTUAL,
|
|
282
353
|
pinned=bool(d.get("pinned", 0)),
|
|
354
|
+
scope=d.get("scope", "personal"),
|
|
355
|
+
shared_with=_jl(d.get("shared_with"), None),
|
|
283
356
|
created_at=d["created_at"],
|
|
284
357
|
)
|
|
285
358
|
|
|
@@ -290,17 +363,29 @@ class DatabaseManager:
|
|
|
290
363
|
(1 if pinned else 0, fact_id),
|
|
291
364
|
)
|
|
292
365
|
|
|
293
|
-
def get_pinned(
|
|
366
|
+
def get_pinned(
|
|
367
|
+
self, profile_id: str,
|
|
368
|
+
include_global: bool = False,
|
|
369
|
+
include_shared: bool = False,
|
|
370
|
+
) -> list[AtomicFact]:
|
|
294
371
|
"""Return all pinned facts for a profile, highest-importance first."""
|
|
372
|
+
where, params = _scope_where(
|
|
373
|
+
profile_id,
|
|
374
|
+
include_global=include_global,
|
|
375
|
+
include_shared=include_shared,
|
|
376
|
+
)
|
|
295
377
|
rows = self.execute(
|
|
296
|
-
"SELECT * FROM atomic_facts WHERE
|
|
378
|
+
f"SELECT * FROM atomic_facts WHERE {where} AND pinned = 1 "
|
|
297
379
|
"ORDER BY importance DESC",
|
|
298
|
-
(
|
|
380
|
+
(*params,),
|
|
299
381
|
)
|
|
300
382
|
return [self._row_to_fact(r) for r in rows]
|
|
301
383
|
|
|
302
384
|
def get_all_facts(
|
|
303
385
|
self, profile_id: str, limit: int | None = None,
|
|
386
|
+
*,
|
|
387
|
+
include_global: bool = False,
|
|
388
|
+
include_shared: bool = False,
|
|
304
389
|
) -> list[AtomicFact]:
|
|
305
390
|
"""All facts for a profile, newest first.
|
|
306
391
|
|
|
@@ -308,22 +393,31 @@ class DatabaseManager:
|
|
|
308
393
|
most-recent N (e.g. the Hopfield channel's 5000 cap) don't deserialize
|
|
309
394
|
the entire table into AtomicFact objects. Default (None) = all facts.
|
|
310
395
|
"""
|
|
396
|
+
where, params = _scope_where(
|
|
397
|
+
profile_id,
|
|
398
|
+
include_global=include_global,
|
|
399
|
+
include_shared=include_shared,
|
|
400
|
+
)
|
|
311
401
|
if limit is not None:
|
|
312
402
|
rows = self.execute(
|
|
313
|
-
"SELECT * FROM atomic_facts WHERE
|
|
403
|
+
f"SELECT * FROM atomic_facts WHERE {where} "
|
|
314
404
|
"ORDER BY created_at DESC LIMIT ?",
|
|
315
|
-
(
|
|
405
|
+
(*params, int(limit)),
|
|
316
406
|
)
|
|
317
407
|
else:
|
|
318
408
|
rows = self.execute(
|
|
319
|
-
"SELECT * FROM atomic_facts WHERE
|
|
320
|
-
(
|
|
409
|
+
f"SELECT * FROM atomic_facts WHERE {where} ORDER BY created_at DESC",
|
|
410
|
+
(*params,),
|
|
321
411
|
)
|
|
322
412
|
return [self._row_to_fact(r) for r in rows]
|
|
323
413
|
|
|
324
414
|
_MAX_FACTS_PER_ENTITY_LOOKUP: int = 100
|
|
325
415
|
|
|
326
|
-
def get_facts_by_entity(
|
|
416
|
+
def get_facts_by_entity(
|
|
417
|
+
self, entity_id: str, profile_id: str,
|
|
418
|
+
include_global: bool = False,
|
|
419
|
+
include_shared: bool = False,
|
|
420
|
+
) -> list[AtomicFact]:
|
|
327
421
|
"""Facts whose canonical_entities JSON array contains *entity_id*.
|
|
328
422
|
|
|
329
423
|
V3.3.14: LIMIT to _MAX_FACTS_PER_ENTITY_LOOKUP (100) to prevent
|
|
@@ -331,19 +425,33 @@ class DatabaseManager:
|
|
|
331
425
|
facts for popular entities (500+) causing 17GB+ memory usage.
|
|
332
426
|
Ordered by created_at DESC so newest facts are always included.
|
|
333
427
|
"""
|
|
428
|
+
where, params = _scope_where(
|
|
429
|
+
profile_id,
|
|
430
|
+
include_global=include_global,
|
|
431
|
+
include_shared=include_shared,
|
|
432
|
+
)
|
|
334
433
|
rows = self.execute(
|
|
335
|
-
"SELECT * FROM atomic_facts WHERE
|
|
434
|
+
f"SELECT * FROM atomic_facts WHERE {where} AND canonical_entities_json LIKE ? "
|
|
336
435
|
"ORDER BY created_at DESC LIMIT ?",
|
|
337
|
-
(
|
|
436
|
+
(*params, f'%"{entity_id}"%', self._MAX_FACTS_PER_ENTITY_LOOKUP),
|
|
338
437
|
)
|
|
339
438
|
return [self._row_to_fact(r) for r in rows]
|
|
340
439
|
|
|
341
|
-
def get_facts_by_type(
|
|
440
|
+
def get_facts_by_type(
|
|
441
|
+
self, fact_type: FactType, profile_id: str,
|
|
442
|
+
include_global: bool = False,
|
|
443
|
+
include_shared: bool = False,
|
|
444
|
+
) -> list[AtomicFact]:
|
|
342
445
|
"""All facts of a given type for a profile."""
|
|
446
|
+
where, params = _scope_where(
|
|
447
|
+
profile_id,
|
|
448
|
+
include_global=include_global,
|
|
449
|
+
include_shared=include_shared,
|
|
450
|
+
)
|
|
343
451
|
rows = self.execute(
|
|
344
|
-
"SELECT * FROM atomic_facts WHERE
|
|
452
|
+
f"SELECT * FROM atomic_facts WHERE {where} AND fact_type = ? "
|
|
345
453
|
"ORDER BY created_at DESC",
|
|
346
|
-
(
|
|
454
|
+
(*params, fact_type.value),
|
|
347
455
|
)
|
|
348
456
|
return [self._row_to_fact(r) for r in rows]
|
|
349
457
|
|
|
@@ -411,10 +519,19 @@ class DatabaseManager:
|
|
|
411
519
|
)
|
|
412
520
|
return n
|
|
413
521
|
|
|
414
|
-
def get_fact_count(
|
|
522
|
+
def get_fact_count(
|
|
523
|
+
self, profile_id: str,
|
|
524
|
+
include_global: bool = False,
|
|
525
|
+
include_shared: bool = False,
|
|
526
|
+
) -> int:
|
|
415
527
|
"""Total fact count for a profile."""
|
|
528
|
+
where, params = _scope_where(
|
|
529
|
+
profile_id,
|
|
530
|
+
include_global=include_global,
|
|
531
|
+
include_shared=include_shared,
|
|
532
|
+
)
|
|
416
533
|
rows = self.execute(
|
|
417
|
-
"SELECT COUNT(*) AS c FROM atomic_facts WHERE
|
|
534
|
+
f"SELECT COUNT(*) AS c FROM atomic_facts WHERE {where}", (*params,),
|
|
418
535
|
)
|
|
419
536
|
return int(rows[0]["c"]) if rows else 0
|
|
420
537
|
|
|
@@ -481,12 +598,19 @@ class DatabaseManager:
|
|
|
481
598
|
|
|
482
599
|
def get_facts_by_memory_id(
|
|
483
600
|
self, memory_id: str, profile_id: str,
|
|
601
|
+
include_global: bool = False,
|
|
602
|
+
include_shared: bool = False,
|
|
484
603
|
) -> list[AtomicFact]:
|
|
485
604
|
"""Get all atomic facts for a given memory_id."""
|
|
605
|
+
where, params = _scope_where(
|
|
606
|
+
profile_id,
|
|
607
|
+
include_global=include_global,
|
|
608
|
+
include_shared=include_shared,
|
|
609
|
+
)
|
|
486
610
|
rows = self.execute(
|
|
487
|
-
"SELECT * FROM atomic_facts WHERE memory_id = ? AND
|
|
611
|
+
f"SELECT * FROM atomic_facts WHERE memory_id = ? AND {where} "
|
|
488
612
|
"ORDER BY confidence DESC",
|
|
489
|
-
(memory_id,
|
|
613
|
+
(memory_id, *params),
|
|
490
614
|
)
|
|
491
615
|
return [self._row_to_fact(r) for r in rows]
|
|
492
616
|
|
|
@@ -513,21 +637,33 @@ class DatabaseManager:
|
|
|
513
637
|
(edge.weight, canonical_id),
|
|
514
638
|
)
|
|
515
639
|
return canonical_id
|
|
640
|
+
_scope = getattr(edge, 'scope', None) or 'personal'
|
|
641
|
+
_shared = _jd(getattr(edge, 'shared_with', None))
|
|
516
642
|
self.execute(
|
|
517
643
|
"""INSERT OR REPLACE INTO graph_edges
|
|
518
|
-
(edge_id, profile_id, source_id, target_id, edge_type, weight, created_at
|
|
519
|
-
|
|
644
|
+
(edge_id, profile_id, source_id, target_id, edge_type, weight, created_at,
|
|
645
|
+
scope, shared_with)
|
|
646
|
+
VALUES (?,?,?,?,?,?,?,?,?)""",
|
|
520
647
|
(edge.edge_id, edge.profile_id, edge.source_id, edge.target_id,
|
|
521
|
-
edge.edge_type.value, edge.weight, edge.created_at),
|
|
648
|
+
edge.edge_type.value, edge.weight, edge.created_at, _scope, _shared),
|
|
522
649
|
)
|
|
523
650
|
return edge.edge_id
|
|
524
651
|
|
|
525
|
-
def get_edges_for_node(
|
|
652
|
+
def get_edges_for_node(
|
|
653
|
+
self, node_id: str, profile_id: str,
|
|
654
|
+
include_global: bool = False,
|
|
655
|
+
include_shared: bool = False,
|
|
656
|
+
) -> list[GraphEdge]:
|
|
526
657
|
"""All edges where node_id is source or target."""
|
|
658
|
+
where, params = _scope_where(
|
|
659
|
+
profile_id,
|
|
660
|
+
include_global=include_global,
|
|
661
|
+
include_shared=include_shared,
|
|
662
|
+
)
|
|
527
663
|
rows = self.execute(
|
|
528
|
-
"SELECT * FROM graph_edges WHERE
|
|
664
|
+
f"SELECT * FROM graph_edges WHERE {where} "
|
|
529
665
|
"AND (source_id = ? OR target_id = ?)",
|
|
530
|
-
(
|
|
666
|
+
(*params, node_id, node_id),
|
|
531
667
|
)
|
|
532
668
|
return [
|
|
533
669
|
GraphEdge(
|
|
@@ -541,24 +677,36 @@ class DatabaseManager:
|
|
|
541
677
|
|
|
542
678
|
def store_temporal_event(self, event: TemporalEvent) -> str:
|
|
543
679
|
"""Persist a temporal event. Returns event_id."""
|
|
680
|
+
_scope = getattr(event, 'scope', None) or 'personal'
|
|
681
|
+
_shared = _jd(getattr(event, 'shared_with', None))
|
|
544
682
|
self.execute(
|
|
545
683
|
"""INSERT OR REPLACE INTO temporal_events
|
|
546
684
|
(event_id, profile_id, entity_id, fact_id,
|
|
547
685
|
observation_date, referenced_date, interval_start, interval_end,
|
|
548
|
-
description)
|
|
549
|
-
VALUES (
|
|
686
|
+
description, scope, shared_with)
|
|
687
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
|
550
688
|
(event.event_id, event.profile_id, event.entity_id, event.fact_id,
|
|
551
689
|
event.observation_date, event.referenced_date,
|
|
552
|
-
event.interval_start, event.interval_end, event.description
|
|
690
|
+
event.interval_start, event.interval_end, event.description,
|
|
691
|
+
_scope, _shared),
|
|
553
692
|
)
|
|
554
693
|
return event.event_id
|
|
555
694
|
|
|
556
|
-
def get_temporal_events(
|
|
695
|
+
def get_temporal_events(
|
|
696
|
+
self, entity_id: str, profile_id: str,
|
|
697
|
+
include_global: bool = False,
|
|
698
|
+
include_shared: bool = False,
|
|
699
|
+
) -> list[TemporalEvent]:
|
|
557
700
|
"""All temporal events for an entity, newest first."""
|
|
701
|
+
where, params = _scope_where(
|
|
702
|
+
profile_id,
|
|
703
|
+
include_global=include_global,
|
|
704
|
+
include_shared=include_shared,
|
|
705
|
+
)
|
|
558
706
|
rows = self.execute(
|
|
559
|
-
"SELECT * FROM temporal_events WHERE
|
|
707
|
+
f"SELECT * FROM temporal_events WHERE {where} AND entity_id = ? "
|
|
560
708
|
"ORDER BY observation_date DESC",
|
|
561
|
-
(
|
|
709
|
+
(*params, entity_id),
|
|
562
710
|
)
|
|
563
711
|
return [
|
|
564
712
|
TemporalEvent(
|
|
@@ -588,7 +736,11 @@ class DatabaseManager:
|
|
|
588
736
|
)
|
|
589
737
|
return {dict(r)["fact_id"]: json.loads(dict(r)["tokens"]) for r in rows}
|
|
590
738
|
|
|
591
|
-
def search_facts_fts(
|
|
739
|
+
def search_facts_fts(
|
|
740
|
+
self, query: str, profile_id: str, limit: int = 20,
|
|
741
|
+
include_global: bool = False,
|
|
742
|
+
include_shared: bool = False,
|
|
743
|
+
) -> list[AtomicFact]:
|
|
592
744
|
"""Full-text search via FTS5, joined to facts table for reconstruction."""
|
|
593
745
|
# v3.6.12 (search-1): the raw query was passed straight into FTS5 MATCH,
|
|
594
746
|
# so any '?', '-', quote, or trailing boolean keyword (AND/OR/NOT) raised
|
|
@@ -599,12 +751,18 @@ class DatabaseManager:
|
|
|
599
751
|
if not tokens:
|
|
600
752
|
return []
|
|
601
753
|
match_expr = " OR ".join(f'"{t}"' for t in tokens)
|
|
754
|
+
where, params = _scope_where(
|
|
755
|
+
profile_id,
|
|
756
|
+
include_global=include_global,
|
|
757
|
+
include_shared=include_shared,
|
|
758
|
+
prefix="f",
|
|
759
|
+
)
|
|
602
760
|
rows = self.execute(
|
|
603
|
-
"""SELECT f.* FROM atomic_facts_fts AS fts
|
|
761
|
+
f"""SELECT f.* FROM atomic_facts_fts AS fts
|
|
604
762
|
JOIN atomic_facts AS f ON f.fact_id = fts.fact_id
|
|
605
|
-
WHERE fts.atomic_facts_fts MATCH ? AND
|
|
763
|
+
WHERE fts.atomic_facts_fts MATCH ? AND {where}
|
|
606
764
|
ORDER BY fts.rank LIMIT ?""",
|
|
607
|
-
(match_expr,
|
|
765
|
+
(match_expr, *params, limit),
|
|
608
766
|
)
|
|
609
767
|
return [self._row_to_fact(r) for r in rows]
|
|
610
768
|
|
|
@@ -641,15 +799,22 @@ class DatabaseManager:
|
|
|
641
799
|
|
|
642
800
|
def get_facts_by_ids(
|
|
643
801
|
self, fact_ids: list[str], profile_id: str,
|
|
802
|
+
include_global: bool = False,
|
|
803
|
+
include_shared: bool = False,
|
|
644
804
|
) -> list[AtomicFact]:
|
|
645
805
|
"""Get multiple facts by their IDs, scoped to a profile."""
|
|
646
806
|
if not fact_ids:
|
|
647
807
|
return []
|
|
808
|
+
where, params = _scope_where(
|
|
809
|
+
profile_id,
|
|
810
|
+
include_global=include_global,
|
|
811
|
+
include_shared=include_shared,
|
|
812
|
+
)
|
|
648
813
|
placeholders = ",".join("?" for _ in fact_ids)
|
|
649
814
|
rows = self.execute(
|
|
650
815
|
f"SELECT * FROM atomic_facts WHERE fact_id IN ({placeholders}) "
|
|
651
|
-
f"AND
|
|
652
|
-
(*fact_ids,
|
|
816
|
+
f"AND {where} ORDER BY created_at DESC",
|
|
817
|
+
(*fact_ids, *params),
|
|
653
818
|
)
|
|
654
819
|
return [self._row_to_fact(r) for r in rows]
|
|
655
820
|
|
|
@@ -820,14 +985,21 @@ class DatabaseManager:
|
|
|
820
985
|
|
|
821
986
|
def get_temporal_events_by_range(
|
|
822
987
|
self, profile_id: str, start_date: str, end_date: str,
|
|
988
|
+
include_global: bool = False,
|
|
989
|
+
include_shared: bool = False,
|
|
823
990
|
) -> list[TemporalEvent]:
|
|
824
991
|
"""Temporal events within a date range (inclusive)."""
|
|
992
|
+
where, params = _scope_where(
|
|
993
|
+
profile_id,
|
|
994
|
+
include_global=include_global,
|
|
995
|
+
include_shared=include_shared,
|
|
996
|
+
)
|
|
825
997
|
rows = self.execute(
|
|
826
|
-
"SELECT * FROM temporal_events WHERE
|
|
998
|
+
f"SELECT * FROM temporal_events WHERE {where} "
|
|
827
999
|
"AND (referenced_date BETWEEN ? AND ? "
|
|
828
1000
|
" OR observation_date BETWEEN ? AND ?) "
|
|
829
1001
|
"ORDER BY observation_date DESC",
|
|
830
|
-
(
|
|
1002
|
+
(*params, start_date, end_date, start_date, end_date),
|
|
831
1003
|
)
|
|
832
1004
|
return [
|
|
833
1005
|
TemporalEvent(
|
|
@@ -51,6 +51,7 @@ from superlocalmemory.storage.migrations import (
|
|
|
51
51
|
M013_bi_temporal_columns as _M013,
|
|
52
52
|
M014_v345_scale_ready as _M014,
|
|
53
53
|
M015_add_pinned_column as _M015,
|
|
54
|
+
M016_add_scope_support as _M016,
|
|
54
55
|
)
|
|
55
56
|
|
|
56
57
|
# Map migration name → module (used for the optional ``verify(conn)`` hook
|
|
@@ -71,6 +72,7 @@ _MODULES = {
|
|
|
71
72
|
_M013.NAME: _M013,
|
|
72
73
|
_M014.NAME: _M014,
|
|
73
74
|
_M015.NAME: _M015,
|
|
75
|
+
_M016.NAME: _M016,
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
logger = logging.getLogger(__name__)
|
|
@@ -134,6 +136,9 @@ DEFERRED_MIGRATIONS: list[Migration] = [
|
|
|
134
136
|
Migration(name=_M014.NAME, db_target="memory", ddl=_M014.DDL),
|
|
135
137
|
# M015 adds pinned column to atomic_facts (v3.4.65 core-memory pins).
|
|
136
138
|
Migration(name=_M015.NAME, db_target="memory", ddl=_M015.DDL),
|
|
139
|
+
# M016 adds scope and shared_with columns to 5 core tables for
|
|
140
|
+
# multi-scope memory support (personal/global/shared).
|
|
141
|
+
Migration(name=_M016.NAME, db_target="memory", ddl=_M016.DDL),
|
|
137
142
|
]
|
|
138
143
|
|
|
139
144
|
|
|
@@ -266,7 +271,18 @@ def _apply_single(
|
|
|
266
271
|
return ("failed", f"cannot record in_progress: {exc}")
|
|
267
272
|
|
|
268
273
|
try:
|
|
269
|
-
conn
|
|
274
|
+
# A migration module may ship a custom apply(conn) for conditional logic
|
|
275
|
+
# that static DDL can't express (e.g. SQLite has no ADD COLUMN IF NOT
|
|
276
|
+
# EXISTS, and ALTER on a missing/already-altered table can't be guarded
|
|
277
|
+
# in one executescript). If present, it runs instead of the DDL string;
|
|
278
|
+
# otherwise the DDL is applied as before. Pure-DDL migrations are
|
|
279
|
+
# unaffected.
|
|
280
|
+
_mod = _MODULES.get(migration.name)
|
|
281
|
+
_apply_fn = getattr(_mod, "apply", None) if _mod is not None else None
|
|
282
|
+
if callable(_apply_fn):
|
|
283
|
+
_apply_fn(conn)
|
|
284
|
+
else:
|
|
285
|
+
conn.executescript(migration.ddl)
|
|
270
286
|
except sqlite3.Error as exc:
|
|
271
287
|
# Best-effort rollback.
|
|
272
288
|
try:
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory v3.6.15
|
|
4
|
+
|
|
5
|
+
"""M016 — scope and shared_with columns on core tables (memory.db, deferred).
|
|
6
|
+
|
|
7
|
+
Adds two columns to each of the 5 core tables for multi-scope memory support:
|
|
8
|
+
|
|
9
|
+
scope TEXT NOT NULL DEFAULT 'personal' — personal | global
|
|
10
|
+
shared_with TEXT — JSON array of profile_ids
|
|
11
|
+
|
|
12
|
+
Existing data retains scope='personal' (backward compatible). Indexes on
|
|
13
|
+
``scope`` and ``(profile_id, scope)`` speed up scope-filtered queries and are
|
|
14
|
+
created HERE (not in schema.create_all_tables) so an upgrading DB whose tables
|
|
15
|
+
predate the scope column doesn't hit "no such column: scope" when the boot-time
|
|
16
|
+
index DDL runs before this migration.
|
|
17
|
+
|
|
18
|
+
Applied via a conditional apply(conn) rather than static DDL because SQLite has
|
|
19
|
+
no ``ADD COLUMN IF NOT EXISTS``: a static ALTER fails on a fresh install (the
|
|
20
|
+
column already exists from create_all_tables) and on a deferred boot where a
|
|
21
|
+
table isn't created yet. apply() guards every step so it is idempotent and
|
|
22
|
+
tolerant of partial/missing tables.
|
|
23
|
+
|
|
24
|
+
Deferred like M006, M011, and M013 because the core tables are bootstrapped
|
|
25
|
+
at engine init, not at migration time. Daemon lifespan calls ``apply_deferred``
|
|
26
|
+
right after engine init so these columns materialise on first boot after upgrade.
|
|
27
|
+
|
|
28
|
+
Author: Varun Pratap Bhardwaj / Qualixar
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import sqlite3
|
|
34
|
+
|
|
35
|
+
NAME = "M016_add_scope_support"
|
|
36
|
+
DB_TARGET = "memory"
|
|
37
|
+
|
|
38
|
+
TABLES = [
|
|
39
|
+
"memories",
|
|
40
|
+
"atomic_facts",
|
|
41
|
+
"canonical_entities",
|
|
42
|
+
"graph_edges",
|
|
43
|
+
"temporal_events",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Retained for the migration log's drift hash and as documentation of intent.
|
|
47
|
+
# apply() below is the authoritative, idempotent executor.
|
|
48
|
+
DDL = ";".join(
|
|
49
|
+
[f"ALTER TABLE {t} ADD COLUMN scope TEXT NOT NULL DEFAULT 'personal'" for t in TABLES]
|
|
50
|
+
+ [f"ALTER TABLE {t} ADD COLUMN shared_with TEXT" for t in TABLES]
|
|
51
|
+
+ [f"CREATE INDEX IF NOT EXISTS idx_{t}_scope ON {t}(scope)" for t in TABLES]
|
|
52
|
+
+ [
|
|
53
|
+
f"CREATE INDEX IF NOT EXISTS idx_{t}_profile_scope ON {t}(profile_id, scope)"
|
|
54
|
+
for t in TABLES
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
|
60
|
+
return conn.execute(
|
|
61
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
|
|
62
|
+
).fetchone() is not None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _column_names(conn: sqlite3.Connection, table: str) -> set[str]:
|
|
66
|
+
return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def apply(conn: sqlite3.Connection) -> None:
|
|
70
|
+
"""Idempotently add scope/shared_with columns + indexes to every core table.
|
|
71
|
+
|
|
72
|
+
Per table: skip if the table doesn't exist yet; add each column only if
|
|
73
|
+
missing (SQLite has no ADD COLUMN IF NOT EXISTS); create indexes with
|
|
74
|
+
IF NOT EXISTS. Safe to run on fresh installs (columns already present),
|
|
75
|
+
upgrades (columns missing), and partial/repeat applies.
|
|
76
|
+
"""
|
|
77
|
+
for t in TABLES:
|
|
78
|
+
if not _table_exists(conn, t):
|
|
79
|
+
continue
|
|
80
|
+
cols = _column_names(conn, t)
|
|
81
|
+
if "scope" not in cols:
|
|
82
|
+
conn.execute(
|
|
83
|
+
f"ALTER TABLE {t} ADD COLUMN scope TEXT NOT NULL DEFAULT 'personal'"
|
|
84
|
+
)
|
|
85
|
+
if "shared_with" not in cols:
|
|
86
|
+
conn.execute(f"ALTER TABLE {t} ADD COLUMN shared_with TEXT")
|
|
87
|
+
conn.execute(f"CREATE INDEX IF NOT EXISTS idx_{t}_scope ON {t}(scope)")
|
|
88
|
+
conn.execute(
|
|
89
|
+
f"CREATE INDEX IF NOT EXISTS idx_{t}_profile_scope ON {t}(profile_id, scope)"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def verify(conn: sqlite3.Connection) -> bool:
|
|
94
|
+
"""Applied only when atomic_facts has the scope column AND its scope index.
|
|
95
|
+
|
|
96
|
+
Checking the index (not just the column) ensures apply() still runs on a
|
|
97
|
+
fresh install where create_all_tables created the column but not the index.
|
|
98
|
+
"""
|
|
99
|
+
# v3.6.15: verify EVERY core table apply() touches — not just atomic_facts.
|
|
100
|
+
# A partial apply (scope added to atomic_facts but not the other tables) must
|
|
101
|
+
# NOT false-pass, or M016 is marked done and the remaining tables are left
|
|
102
|
+
# permanently without the scope column. Absent tables are skipped, matching
|
|
103
|
+
# apply()'s own skip-missing-table contract.
|
|
104
|
+
for t in TABLES:
|
|
105
|
+
try:
|
|
106
|
+
info = conn.execute(f"PRAGMA table_info({t})").fetchall()
|
|
107
|
+
except sqlite3.Error:
|
|
108
|
+
return False
|
|
109
|
+
if not info:
|
|
110
|
+
continue # table absent on this DB — apply() skips it too
|
|
111
|
+
cols = {r[1] for r in info}
|
|
112
|
+
if "scope" not in cols:
|
|
113
|
+
return False
|
|
114
|
+
idx = conn.execute(
|
|
115
|
+
"SELECT 1 FROM sqlite_master WHERE type='index' AND name=?",
|
|
116
|
+
(f"idx_{t}_scope",),
|
|
117
|
+
).fetchone()
|
|
118
|
+
if idx is None:
|
|
119
|
+
return False
|
|
120
|
+
return True
|
|
@@ -119,6 +119,8 @@ class MemoryRecord:
|
|
|
119
119
|
|
|
120
120
|
memory_id: str = field(default_factory=_new_id)
|
|
121
121
|
profile_id: str = "default"
|
|
122
|
+
scope: str = "personal"
|
|
123
|
+
shared_with: list[str] | None = None
|
|
122
124
|
content: str = ""
|
|
123
125
|
session_id: str = ""
|
|
124
126
|
speaker: str = "" # Who said this
|
|
@@ -139,6 +141,8 @@ class AtomicFact:
|
|
|
139
141
|
fact_id: str = field(default_factory=_new_id)
|
|
140
142
|
memory_id: str = "" # Source memory this was extracted from
|
|
141
143
|
profile_id: str = "default"
|
|
144
|
+
scope: str = "personal"
|
|
145
|
+
shared_with: list[str] | None = None
|
|
142
146
|
content: str = "" # Atomic fact statement
|
|
143
147
|
fact_type: FactType = FactType.SEMANTIC
|
|
144
148
|
|
|
@@ -193,6 +197,8 @@ class CanonicalEntity:
|
|
|
193
197
|
|
|
194
198
|
entity_id: str = field(default_factory=_new_id)
|
|
195
199
|
profile_id: str = "default"
|
|
200
|
+
scope: str = "personal"
|
|
201
|
+
shared_with: list[str] | None = None
|
|
196
202
|
canonical_name: str = ""
|
|
197
203
|
entity_type: str = "" # person / place / org / concept / event
|
|
198
204
|
first_seen: str = field(default_factory=_now)
|
|
@@ -251,6 +257,8 @@ class TemporalEvent:
|
|
|
251
257
|
|
|
252
258
|
event_id: str = field(default_factory=_new_id)
|
|
253
259
|
profile_id: str = "default"
|
|
260
|
+
scope: str = "personal"
|
|
261
|
+
shared_with: list[str] | None = None
|
|
254
262
|
entity_id: str = "" # FK to CanonicalEntity
|
|
255
263
|
fact_id: str = "" # FK to AtomicFact
|
|
256
264
|
observation_date: str | None = None
|
|
@@ -266,6 +274,8 @@ class GraphEdge:
|
|
|
266
274
|
|
|
267
275
|
edge_id: str = field(default_factory=_new_id)
|
|
268
276
|
profile_id: str = "default"
|
|
277
|
+
scope: str = "personal"
|
|
278
|
+
shared_with: list[str] | None = None
|
|
269
279
|
source_id: str = "" # Fact ID or Entity ID
|
|
270
280
|
target_id: str = "" # Fact ID or Entity ID
|
|
271
281
|
edge_type: EdgeType = EdgeType.ENTITY
|