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.
Files changed (147) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +189 -740
  4. package/package.json +12 -5
  5. package/plugin/.claude-plugin/plugin.json +20 -0
  6. package/plugin/.mcp.json +12 -0
  7. package/plugin/CLAUDE.md +44 -0
  8. package/plugin/_GENERATED.md +6 -0
  9. package/plugin/agents/slm-memory-advisor.md +44 -0
  10. package/plugin/agents/slm-optimize-advisor.md +38 -0
  11. package/plugin/hooks/hooks.json +14 -0
  12. package/plugin/requirements.txt +1 -0
  13. package/plugin/scripts/ensure-venv.bat +122 -0
  14. package/plugin/scripts/ensure-venv.sh +105 -0
  15. package/plugin/scripts/slm-launch +15 -0
  16. package/plugin/scripts/slm-launch.bat +17 -0
  17. package/plugin/settings.json +16 -0
  18. package/plugin/skills/slm-cache/SKILL.md +140 -0
  19. package/plugin/skills/slm-compress/SKILL.md +143 -0
  20. package/plugin/skills/slm-graph/SKILL.md +300 -0
  21. package/plugin/skills/slm-recall/SKILL.md +204 -0
  22. package/plugin/skills/slm-remember/SKILL.md +194 -0
  23. package/plugin/skills/slm-session/SKILL.md +207 -0
  24. package/plugin/skills/slm-status/SKILL.md +149 -0
  25. package/plugin-src/.mcp.json +12 -0
  26. package/plugin-src/agents/slm-memory-advisor.md +44 -0
  27. package/plugin-src/agents/slm-optimize-advisor.md +38 -0
  28. package/plugin-src/commands/slm-optimize.md +22 -0
  29. package/plugin-src/commands/slm-recall.md +16 -0
  30. package/plugin-src/commands/slm-remember.md +16 -0
  31. package/plugin-src/commands/slm-status.md +15 -0
  32. package/plugin-src/hooks/.gitkeep +0 -0
  33. package/plugin-src/hooks/hooks.json +14 -0
  34. package/plugin-src/manifest.json +25 -0
  35. package/plugin-src/requirements.txt +1 -0
  36. package/plugin-src/rules/AGENTS.md +91 -0
  37. package/plugin-src/rules/CLAUDE.md.fragment +44 -0
  38. package/plugin-src/scripts/ensure-venv.bat +122 -0
  39. package/plugin-src/scripts/ensure-venv.sh +105 -0
  40. package/plugin-src/scripts/slm-launch +15 -0
  41. package/plugin-src/scripts/slm-launch.bat +17 -0
  42. package/plugin-src/settings.json +16 -0
  43. package/plugin-src/skills/slm-cache/SKILL.md +140 -0
  44. package/plugin-src/skills/slm-compress/SKILL.md +143 -0
  45. package/plugin-src/skills/slm-graph/SKILL.md +300 -0
  46. package/plugin-src/skills/slm-recall/SKILL.md +204 -0
  47. package/plugin-src/skills/slm-remember/SKILL.md +194 -0
  48. package/plugin-src/skills/slm-session/SKILL.md +207 -0
  49. package/plugin-src/skills/slm-status/SKILL.md +149 -0
  50. package/pyproject.toml +6 -2
  51. package/scripts/__tests__/build-plugin.test.mjs +613 -0
  52. package/scripts/_savings_math.py +270 -0
  53. package/scripts/build-plugin.js +742 -0
  54. package/scripts/dogfood_savings.py +490 -0
  55. package/scripts/install-skills.ps1 +4 -334
  56. package/scripts/install-skills.sh +4 -435
  57. package/scripts/postinstall-interactive.js +0 -27
  58. package/scripts/postinstall.js +21 -2
  59. package/src/superlocalmemory/__init__.py +1 -1
  60. package/src/superlocalmemory/cli/_lazy_init.py +115 -0
  61. package/src/superlocalmemory/cli/commands.py +439 -41
  62. package/src/superlocalmemory/cli/main.py +92 -4
  63. package/src/superlocalmemory/cli/setup_wizard.py +47 -6
  64. package/src/superlocalmemory/core/backend_orchestrator.py +12 -8
  65. package/src/superlocalmemory/core/config.py +194 -9
  66. package/src/superlocalmemory/core/embeddings.py +10 -5
  67. package/src/superlocalmemory/core/engine.py +76 -5
  68. package/src/superlocalmemory/core/fact_consolidator.py +20 -3
  69. package/src/superlocalmemory/core/platform_utils.py +8 -0
  70. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  71. package/src/superlocalmemory/core/recall_worker.py +7 -0
  72. package/src/superlocalmemory/core/store_pipeline.py +23 -1
  73. package/src/superlocalmemory/core/worker_pool.py +14 -2
  74. package/src/superlocalmemory/hooks/claude_code_hooks.py +27 -3
  75. package/src/superlocalmemory/hooks/portable_kit.py +506 -0
  76. package/src/superlocalmemory/hooks/session_registry.py +8 -4
  77. package/src/superlocalmemory/infra/cloud_backup.py +99 -23
  78. package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -2
  79. package/src/superlocalmemory/mcp/_pool_adapter.py +15 -6
  80. package/src/superlocalmemory/mcp/cli_fallback.py +602 -0
  81. package/src/superlocalmemory/mcp/server.py +75 -4
  82. package/src/superlocalmemory/mcp/tools_code_graph.py +3 -3
  83. package/src/superlocalmemory/mcp/tools_core.py +37 -4
  84. package/src/superlocalmemory/mcp/tools_v3.py +6 -1
  85. package/src/superlocalmemory/mcp/tools_v33.py +8 -4
  86. package/src/superlocalmemory/optimize/cache/boundary_store.py +25 -6
  87. package/src/superlocalmemory/optimize/cache/centroid_store.py +27 -4
  88. package/src/superlocalmemory/optimize/cache/manager.py +92 -6
  89. package/src/superlocalmemory/optimize/cache/semantic.py +20 -1
  90. package/src/superlocalmemory/optimize/compress/ccr.py +12 -0
  91. package/src/superlocalmemory/optimize/compress/router.py +46 -13
  92. package/src/superlocalmemory/optimize/config/schema.py +6 -0
  93. package/src/superlocalmemory/optimize/proxy/_helpers.py +111 -8
  94. package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +14 -4
  95. package/src/superlocalmemory/optimize/proxy/gemini_surface.py +23 -6
  96. package/src/superlocalmemory/optimize/proxy/openai_surface.py +10 -4
  97. package/src/superlocalmemory/optimize/proxy/server.py +11 -0
  98. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +246 -0
  99. package/src/superlocalmemory/optimize/storage/db.py +30 -0
  100. package/src/superlocalmemory/retrieval/bm25_channel.py +12 -2
  101. package/src/superlocalmemory/retrieval/engine.py +36 -3
  102. package/src/superlocalmemory/retrieval/entity_channel.py +5 -5
  103. package/src/superlocalmemory/retrieval/hopfield_channel.py +10 -2
  104. package/src/superlocalmemory/retrieval/semantic_channel.py +10 -2
  105. package/src/superlocalmemory/server/recall_serializer.py +3 -1
  106. package/src/superlocalmemory/server/unified_daemon.py +156 -16
  107. package/src/superlocalmemory/storage/database.py +215 -43
  108. package/src/superlocalmemory/storage/migration_runner.py +17 -1
  109. package/src/superlocalmemory/storage/migrations/M016_add_scope_support.py +120 -0
  110. package/src/superlocalmemory/storage/models.py +10 -0
  111. package/src/superlocalmemory/storage/schema.py +15 -10
  112. package/src/superlocalmemory/ui/css/legacy-dashboard.css +18 -0
  113. package/src/superlocalmemory/ui/css/neural-glass.css +5 -0
  114. package/src/superlocalmemory/ui/index.html +2 -2
  115. package/src/superlocalmemory/ui/js/core.js +98 -0
  116. package/src/superlocalmemory/ui/js/dashboard.js +8 -1
  117. package/src/superlocalmemory/ui/js/ide-status.js +16 -3
  118. package/src/superlocalmemory/ui/js/math-health.js +15 -3
  119. package/src/superlocalmemory/ui/js/optimize.js +18 -2
  120. package/src/superlocalmemory/ui/js/trust-dashboard.js +10 -1
  121. package/src/superlocalmemory.egg-info/PKG-INFO +191 -741
  122. package/src/superlocalmemory.egg-info/SOURCES.txt +7 -9
  123. package/src/superlocalmemory.egg-info/requires.txt +1 -0
  124. package/ide/skills/slm-build-graph/SKILL.md +0 -423
  125. package/ide/skills/slm-list-recent/SKILL.md +0 -348
  126. package/ide/skills/slm-recall/SKILL.md +0 -326
  127. package/ide/skills/slm-remember/SKILL.md +0 -194
  128. package/ide/skills/slm-show-patterns/SKILL.md +0 -224
  129. package/ide/skills/slm-status/SKILL.md +0 -363
  130. package/ide/skills/slm-switch-profile/SKILL.md +0 -442
  131. package/skills/slm-build-graph/SKILL.md +0 -423
  132. package/skills/slm-list-recent/SKILL.md +0 -348
  133. package/skills/slm-optimize/README.md +0 -55
  134. package/skills/slm-optimize/SKILL.md +0 -139
  135. package/skills/slm-recall/SKILL.md +0 -343
  136. package/skills/slm-remember/SKILL.md +0 -194
  137. package/skills/slm-show-patterns/SKILL.md +0 -224
  138. package/skills/slm-status/SKILL.md +0 -363
  139. package/skills/slm-switch-profile/SKILL.md +0 -442
  140. package/src/superlocalmemory/cli/doctor_cmd.py +0 -152
  141. package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +0 -423
  142. package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +0 -348
  143. package/src/superlocalmemory/skills/slm-recall/SKILL.md +0 -343
  144. package/src/superlocalmemory/skills/slm-remember/SKILL.md +0 -194
  145. package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +0 -224
  146. package/src/superlocalmemory/skills/slm-status/SKILL.md +0 -363
  147. 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
- VALUES (?,?,?,?,?,?,?,?,?)""",
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
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
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(self, profile_id: str) -> list[AtomicFact]:
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 profile_id = ? AND pinned = 1 "
378
+ f"SELECT * FROM atomic_facts WHERE {where} AND pinned = 1 "
297
379
  "ORDER BY importance DESC",
298
- (profile_id,),
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 profile_id = ? "
403
+ f"SELECT * FROM atomic_facts WHERE {where} "
314
404
  "ORDER BY created_at DESC LIMIT ?",
315
- (profile_id, int(limit)),
405
+ (*params, int(limit)),
316
406
  )
317
407
  else:
318
408
  rows = self.execute(
319
- "SELECT * FROM atomic_facts WHERE profile_id = ? ORDER BY created_at DESC",
320
- (profile_id,),
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(self, entity_id: str, profile_id: str) -> list[AtomicFact]:
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 profile_id = ? AND canonical_entities_json LIKE ? "
434
+ f"SELECT * FROM atomic_facts WHERE {where} AND canonical_entities_json LIKE ? "
336
435
  "ORDER BY created_at DESC LIMIT ?",
337
- (profile_id, f'%"{entity_id}"%', self._MAX_FACTS_PER_ENTITY_LOOKUP),
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(self, fact_type: FactType, profile_id: str) -> list[AtomicFact]:
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 profile_id = ? AND fact_type = ? "
452
+ f"SELECT * FROM atomic_facts WHERE {where} AND fact_type = ? "
345
453
  "ORDER BY created_at DESC",
346
- (profile_id, fact_type.value),
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(self, profile_id: str) -> int:
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 profile_id = ?", (profile_id,),
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 profile_id = ? "
611
+ f"SELECT * FROM atomic_facts WHERE memory_id = ? AND {where} "
488
612
  "ORDER BY confidence DESC",
489
- (memory_id, profile_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
- VALUES (?,?,?,?,?,?,?)""",
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(self, node_id: str, profile_id: str) -> list[GraphEdge]:
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 profile_id = ? "
664
+ f"SELECT * FROM graph_edges WHERE {where} "
529
665
  "AND (source_id = ? OR target_id = ?)",
530
- (profile_id, node_id, node_id),
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(self, entity_id: str, profile_id: str) -> list[TemporalEvent]:
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 profile_id = ? AND entity_id = ? "
707
+ f"SELECT * FROM temporal_events WHERE {where} AND entity_id = ? "
560
708
  "ORDER BY observation_date DESC",
561
- (profile_id, entity_id),
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(self, query: str, profile_id: str, limit: int = 20) -> list[AtomicFact]:
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 f.profile_id = ?
763
+ WHERE fts.atomic_facts_fts MATCH ? AND {where}
606
764
  ORDER BY fts.rank LIMIT ?""",
607
- (match_expr, profile_id, limit),
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 profile_id = ? ORDER BY created_at DESC",
652
- (*fact_ids, profile_id),
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 profile_id = ? "
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
- (profile_id, start_date, end_date, start_date, end_date),
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.executescript(migration.ddl)
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