superlocalmemory 3.6.14 → 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 (66) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +5 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +5 -4
  6. package/plugin/agents/slm-memory-advisor.md +5 -4
  7. package/plugin/agents/slm-optimize-advisor.md +1 -1
  8. package/plugin/requirements.txt +1 -1
  9. package/plugin/skills/slm-cache/SKILL.md +1 -1
  10. package/plugin/skills/slm-compress/SKILL.md +1 -1
  11. package/plugin/skills/slm-graph/SKILL.md +1 -1
  12. package/plugin/skills/slm-recall/SKILL.md +10 -2
  13. package/plugin/skills/slm-remember/SKILL.md +14 -2
  14. package/plugin/skills/slm-session/SKILL.md +1 -1
  15. package/plugin/skills/slm-status/SKILL.md +1 -1
  16. package/plugin-src/agents/slm-memory-advisor.md +5 -4
  17. package/plugin-src/agents/slm-optimize-advisor.md +1 -1
  18. package/plugin-src/commands/slm-optimize.md +1 -1
  19. package/plugin-src/commands/slm-recall.md +1 -1
  20. package/plugin-src/commands/slm-remember.md +2 -2
  21. package/plugin-src/commands/slm-status.md +1 -1
  22. package/plugin-src/manifest.json +1 -1
  23. package/plugin-src/requirements.txt +1 -1
  24. package/plugin-src/rules/AGENTS.md +5 -4
  25. package/plugin-src/rules/CLAUDE.md.fragment +5 -4
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-recall/SKILL.md +10 -2
  30. package/plugin-src/skills/slm-remember/SKILL.md +14 -2
  31. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  33. package/pyproject.toml +1 -1
  34. package/scripts/build-plugin.js +1 -1
  35. package/src/superlocalmemory/__init__.py +1 -1
  36. package/src/superlocalmemory/cli/commands.py +91 -2
  37. package/src/superlocalmemory/cli/main.py +45 -0
  38. package/src/superlocalmemory/cli/setup_wizard.py +27 -0
  39. package/src/superlocalmemory/core/backend_orchestrator.py +12 -8
  40. package/src/superlocalmemory/core/config.py +115 -0
  41. package/src/superlocalmemory/core/engine.py +74 -3
  42. package/src/superlocalmemory/core/fact_consolidator.py +20 -3
  43. package/src/superlocalmemory/core/platform_utils.py +8 -0
  44. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  45. package/src/superlocalmemory/core/recall_worker.py +7 -0
  46. package/src/superlocalmemory/core/store_pipeline.py +23 -1
  47. package/src/superlocalmemory/core/worker_pool.py +14 -2
  48. package/src/superlocalmemory/hooks/session_registry.py +8 -4
  49. package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -2
  50. package/src/superlocalmemory/mcp/_pool_adapter.py +15 -6
  51. package/src/superlocalmemory/mcp/tools_core.py +25 -0
  52. package/src/superlocalmemory/mcp/tools_v3.py +6 -1
  53. package/src/superlocalmemory/mcp/tools_v33.py +8 -4
  54. package/src/superlocalmemory/retrieval/bm25_channel.py +12 -2
  55. package/src/superlocalmemory/retrieval/engine.py +36 -3
  56. package/src/superlocalmemory/retrieval/entity_channel.py +5 -5
  57. package/src/superlocalmemory/retrieval/hopfield_channel.py +10 -2
  58. package/src/superlocalmemory/retrieval/semantic_channel.py +10 -2
  59. package/src/superlocalmemory/server/unified_daemon.py +132 -10
  60. package/src/superlocalmemory/storage/database.py +215 -43
  61. package/src/superlocalmemory/storage/migration_runner.py +17 -1
  62. package/src/superlocalmemory/storage/migrations/M016_add_scope_support.py +120 -0
  63. package/src/superlocalmemory/storage/models.py +10 -0
  64. package/src/superlocalmemory/storage/schema.py +15 -10
  65. package/src/superlocalmemory.egg-info/PKG-INFO +6 -3
  66. package/src/superlocalmemory.egg-info/SOURCES.txt +1 -0
@@ -94,6 +94,48 @@ def _cmd_help_optimize(args: Namespace) -> None:
94
94
  # ---- end SLM v3.6 Optimize dispatch functions ----
95
95
 
96
96
 
97
+ def cmd_session(args: Namespace) -> None:
98
+ """#49: Open/close a session locally via the daemon — no model roundtrip,
99
+ so a shell hook (e.g. Claude session-start / /quit) can call it directly.
100
+ """
101
+ from superlocalmemory.cli.daemon import (
102
+ daemon_request, ensure_daemon, is_daemon_running,
103
+ )
104
+
105
+ action = getattr(args, "session_command", None)
106
+ if action not in ("open", "close"):
107
+ print("Usage: slm session {open|close} "
108
+ "[--session-id ID] [--project-path PATH] [--query Q]")
109
+ return
110
+
111
+ if not is_daemon_running():
112
+ ensure_daemon()
113
+
114
+ if action == "open":
115
+ body = {
116
+ "project_path": getattr(args, "project_path", "") or "",
117
+ "query": getattr(args, "query", "") or "",
118
+ "max_results": int(getattr(args, "max_results", 10) or 10),
119
+ }
120
+ resp = daemon_request("POST", "/session/open", body)
121
+ if resp and resp.get("ok"):
122
+ print(f"Session opened — warmed {resp.get('warmed', 0)} memories "
123
+ f"(query: {resp.get('query', '')})")
124
+ else:
125
+ print("Session open failed (daemon unreachable?)")
126
+ return
127
+
128
+ # close
129
+ body = {"session_id": getattr(args, "session_id", "") or ""}
130
+ resp = daemon_request("POST", "/session/close", body)
131
+ if resp and resp.get("ok"):
132
+ sid = resp.get("session_id") or "(most recent)"
133
+ print(f"Session closed: {sid} — "
134
+ f"{resp.get('summary_events_created', 0)} summary event(s) created")
135
+ else:
136
+ print("Session close failed (daemon unreachable?)")
137
+
138
+
97
139
  def dispatch(args: Namespace) -> None:
98
140
  """Route CLI command to the appropriate handler."""
99
141
  # Auto-install/upgrade hooks on version change (single file read, ~0.1ms)
@@ -128,6 +170,7 @@ def dispatch(args: Namespace) -> None:
128
170
  "profile": cmd_profile,
129
171
  "hooks": cmd_hooks,
130
172
  "session-context": cmd_session_context,
173
+ "session": cmd_session, # #49: local session open/close for hooks
131
174
  "observe": cmd_observe,
132
175
  # V3.3 commands
133
176
  "decay": cmd_decay,
@@ -993,6 +1036,19 @@ def cmd_remember(args: Namespace) -> None:
993
1036
 
994
1037
  use_json = getattr(args, 'json', False)
995
1038
  sync_mode = getattr(args, 'sync_mode', False)
1039
+ # v3.6.15 multi-scope: scope=None means "not specified" → resolve to the
1040
+ # configured default_scope (personal) at the daemon / engine boundary.
1041
+ # Shared memory is opt-in, so an unset --scope always stays private.
1042
+ scope = getattr(args, 'scope', None)
1043
+ # v3.6.15: --shared-with is a comma-separated string on the CLI, but the
1044
+ # daemon (RememberRequest) and engine expect list[str]. Parse here so an
1045
+ # explicit `--shared-with a,b` doesn't 422 at the daemon and silently fall
1046
+ # back to a personal write.
1047
+ _sw_raw = getattr(args, 'shared_with', None)
1048
+ shared_with = (
1049
+ [s.strip() for s in _sw_raw.split(",") if s.strip()]
1050
+ if isinstance(_sw_raw, str) and _sw_raw.strip() else _sw_raw
1051
+ )
996
1052
 
997
1053
  # V3.3.21: Route through daemon for instant remember (no cold start).
998
1054
  # If daemon is running, send request directly (~0.1s).
@@ -1005,6 +1061,8 @@ def cmd_remember(args: Namespace) -> None:
1005
1061
  result = daemon_request("POST", "/remember", {
1006
1062
  "content": args.content,
1007
1063
  "tags": args.tags or "",
1064
+ "scope": scope,
1065
+ "shared_with": shared_with,
1008
1066
  })
1009
1067
  if result and "fact_ids" in result:
1010
1068
  if use_json:
@@ -1020,9 +1078,20 @@ def cmd_remember(args: Namespace) -> None:
1020
1078
  # NO subprocess spawn. Daemon's background loop picks up pending memories.
1021
1079
  from superlocalmemory.cli.pending_store import store_pending
1022
1080
 
1081
+ # v3.6.15 multi-scope: carry an explicit non-personal scope into the
1082
+ # pending row's metadata so the materializer replays the right
1083
+ # visibility. Unset / personal carries nothing — byte-identical to
1084
+ # pre-3.6.15 pending rows.
1085
+ _pending_meta = None
1086
+ if scope and scope != "personal":
1087
+ _pending_meta = {"scope": scope}
1088
+ if shared_with:
1089
+ _pending_meta["shared_with"] = shared_with
1090
+
1023
1091
  row_id = store_pending(
1024
1092
  content=args.content,
1025
1093
  tags=args.tags or "",
1094
+ metadata=_pending_meta,
1026
1095
  )
1027
1096
 
1028
1097
  if use_json:
@@ -1040,8 +1109,13 @@ def cmd_remember(args: Namespace) -> None:
1040
1109
  engine = MemoryEngine(config)
1041
1110
  engine.initialize()
1042
1111
 
1112
+ # v3.6.15: resolve an unset scope to the configured default_scope.
1113
+ _scope = scope or getattr(getattr(config, "scope", None), "default_scope", "personal")
1043
1114
  metadata = {"tags": args.tags} if args.tags else {}
1044
- fact_ids = engine.store(args.content, metadata=metadata)
1115
+ fact_ids = engine.store(
1116
+ args.content, metadata=metadata,
1117
+ scope=_scope, shared_with=shared_with,
1118
+ )
1045
1119
  except Exception as exc:
1046
1120
  if use_json:
1047
1121
  from superlocalmemory.cli.json_output import json_print
@@ -1064,6 +1138,11 @@ def cmd_remember(args: Namespace) -> None:
1064
1138
  def cmd_recall(args: Namespace) -> None:
1065
1139
  """Search memories via the engine — routes through daemon if available."""
1066
1140
  use_json = getattr(args, 'json', False)
1141
+ # v3.6.15: None = "not specified" → daemon/engine resolves the configured
1142
+ # default (shared-off). Only an explicit --include-global / --no-global
1143
+ # produces True/False here.
1144
+ include_global = getattr(args, 'include_global', None)
1145
+ include_shared = getattr(args, 'include_shared', None)
1067
1146
 
1068
1147
  # V3.3.21: Route through daemon for instant response (no cold start).
1069
1148
  # Falls back to direct engine if daemon not running.
@@ -1077,10 +1156,18 @@ def cmd_recall(args: Namespace) -> None:
1077
1156
  from urllib.parse import quote
1078
1157
  session_id = f"cli:{os.getppid()}"
1079
1158
  fast_qs = "&fast=true" if getattr(args, "fast", False) else ""
1159
+ # Only send scope flags when the user set them explicitly; absent =
1160
+ # let the daemon resolve the configured default. (Never emit
1161
+ # "none" — that would parse as a missing/false value.)
1162
+ scope_qs = ""
1163
+ if include_global is not None:
1164
+ scope_qs += f"&include_global={str(include_global).lower()}"
1165
+ if include_shared is not None:
1166
+ scope_qs += f"&include_shared={str(include_shared).lower()}"
1080
1167
  result = daemon_request(
1081
1168
  "GET",
1082
1169
  f"/recall?q={quote(args.query)}&limit={args.limit}"
1083
- f"&session_id={quote(session_id)}{fast_qs}",
1170
+ f"&session_id={quote(session_id)}{fast_qs}{scope_qs}",
1084
1171
  )
1085
1172
  if result and "results" in result:
1086
1173
  # Format daemon response same as engine response
@@ -1114,6 +1201,8 @@ def cmd_recall(args: Namespace) -> None:
1114
1201
  response = engine.recall(
1115
1202
  args.query, limit=args.limit,
1116
1203
  fast=getattr(args, "fast", False),
1204
+ include_global=include_global,
1205
+ include_shared=include_shared,
1117
1206
  )
1118
1207
  except Exception as exc:
1119
1208
  if use_json:
@@ -235,6 +235,15 @@ def main() -> None:
235
235
  "--sync", dest="sync_mode", action="store_true",
236
236
  help="Wait for completion (default: async background processing)",
237
237
  )
238
+ remember_p.add_argument(
239
+ "--scope", default=None, choices=("personal", "shared", "global"),
240
+ help="Memory scope: personal, shared, or global. Unset uses the "
241
+ "configured default_scope (personal). Shared memory is opt-in.",
242
+ )
243
+ remember_p.add_argument(
244
+ "--shared-with", default=None,
245
+ help="Comma-separated profile IDs for shared scope",
246
+ )
238
247
 
239
248
  # v3.6.12 (parity-3): `search` is an alias of `recall` so the CLI has the
240
249
  # same search verb the MCP exposes (handlers dict maps both to cmd_recall).
@@ -251,6 +260,26 @@ def main() -> None:
251
260
  "Other 4 channels (semantic, lexical, temporal, structural) still run. "
252
261
  "Use when you need recall before a tool call (e.g. before WebSearch).",
253
262
  )
263
+ # v3.6.15: shared memory is opt-in. Unset (None) → resolve the configured
264
+ # default (recall_include_global/shared, both False by default). Explicit
265
+ # flags override per-call. default=None on BOTH members of each pair so the
266
+ # store_false's implicit default=True can't sneak back in.
267
+ recall_p.add_argument(
268
+ "--include-global", dest="include_global", action="store_true", default=None,
269
+ help="Include global-scope facts in retrieval (opt-in; default off)",
270
+ )
271
+ recall_p.add_argument(
272
+ "--no-global", dest="include_global", action="store_false", default=None,
273
+ help="Exclude global-scope facts from retrieval",
274
+ )
275
+ recall_p.add_argument(
276
+ "--include-shared", dest="include_shared", action="store_true", default=None,
277
+ help="Include facts shared with this profile (opt-in; default off)",
278
+ )
279
+ recall_p.add_argument(
280
+ "--no-shared", dest="include_shared", action="store_false", default=None,
281
+ help="Exclude shared-scope facts from retrieval",
282
+ )
254
283
 
255
284
  forget_p = sub.add_parser("forget", help="Delete memories matching a query (fuzzy)")
256
285
  forget_p.add_argument("query", help="Query to match for deletion")
@@ -380,6 +409,22 @@ def main() -> None:
380
409
  )
381
410
  ctx_p.add_argument("--json", action="store_true", help="Output structured JSON (agent-native)")
382
411
 
412
+ # #49: local session open/close (for hooks; no model roundtrip)
413
+ session_p = sub.add_parser(
414
+ "session", help="Open/close a session locally (for hooks; no model roundtrip)"
415
+ )
416
+ session_sub = session_p.add_subparsers(dest="session_command", title="session actions")
417
+ sopen_p = session_sub.add_parser("open", help="Warm session context")
418
+ sopen_p.add_argument("--project-path", default="", help="Project path to derive the warm query")
419
+ sopen_p.add_argument("--query", default="", help="Explicit warm query")
420
+ sopen_p.add_argument("--max-results", type=int, default=10, help="Max memories to warm (default 10)")
421
+ sclose_p = session_sub.add_parser(
422
+ "close", help="Close session, create temporal summaries"
423
+ )
424
+ sclose_p.add_argument(
425
+ "--session-id", default="", help="Session to close (default: most recent)"
426
+ )
427
+
383
428
  obs_p = sub.add_parser("observe", help="Auto-capture content (pipe or argument)")
384
429
  obs_p.add_argument("content", nargs="?", default="", help="Content to evaluate")
385
430
 
@@ -379,6 +379,33 @@ def run_wizard(auto: bool = False) -> None:
379
379
  mode_map = {"a": Mode.A, "b": Mode.B, "c": Mode.C}
380
380
  config = SLMConfig.for_mode(mode_map[choice])
381
381
 
382
+ # -- Multi-scope (shared memory) opt-in — v3.6.15 --
383
+ # OFF by default: your memories stay private to this profile (3.6.14 behaviour).
384
+ # Enabling only turns ON recall VISIBILITY of other profiles' shared/global facts;
385
+ # your own writes still default to 'personal' (mark a memory shared/global per call
386
+ # with --scope). Existing users can flip this later in the `scope` section of
387
+ # mode_a/b/c.json. See docs/shared-memory.md.
388
+ print()
389
+ print(" Shared memory lets other local profiles' 'shared'/'global' memories")
390
+ print(" appear in your recall. It is OFF by default — your memories stay private.")
391
+ if interactive:
392
+ sm_choice = _prompt(
393
+ " See shared/global memories from other profiles by default? [y/N] (default: N): ",
394
+ "n",
395
+ ).lower()
396
+ else:
397
+ sm_choice = "n"
398
+ if sm_choice in ("y", "yes"):
399
+ from superlocalmemory.core.config import ScopeConfig
400
+ config.scope = ScopeConfig(
401
+ default_scope="personal",
402
+ recall_include_global=True,
403
+ recall_include_shared=True,
404
+ )
405
+ print(" ✓ Shared-memory recall ENABLED (your own writes still default to personal).")
406
+ else:
407
+ print(" ✓ Shared memory OFF (default) — enable later in mode_*.json if needed.")
408
+
382
409
  if choice == "b":
383
410
  print()
384
411
  if shutil.which("ollama"):
@@ -342,15 +342,15 @@ class BackendOrchestrator:
342
342
  count: int = 0, error: str = "") -> None:
343
343
  self._backend_cache[name] = status
344
344
  try:
345
- self._db.conn.execute(
345
+ # #47 fix: DatabaseManager has no `.conn`; execute() commits itself.
346
+ self._db.execute(
346
347
  "INSERT OR REPLACE INTO backend_status "
347
348
  "(backend_name, status, record_count, error_message, last_sync_at) "
348
349
  "VALUES (?, ?, ?, ?, datetime('now'))",
349
350
  (name, status, count, error),
350
351
  )
351
- self._db.conn.commit()
352
- except Exception:
353
- pass
352
+ except Exception as exc:
353
+ logger.debug("backend_status update failed for %s: %s", name, exc)
354
354
 
355
355
  # ------------------------------------------------------------------
356
356
  # Internal: Schema
@@ -361,10 +361,14 @@ class BackendOrchestrator:
361
361
  from superlocalmemory.storage.schema_v345 import (
362
362
  apply_migration, schema_version_applied,
363
363
  )
364
- if not schema_version_applied(self._db.conn):
365
- result = apply_migration(self._db.conn)
366
- if result.get("errors"):
367
- logger.warning("Schema v3.4.5 had errors: %s", result["errors"])
364
+ # #47 fix: use raw_connection() — DatabaseManager has no `.conn`,
365
+ # so the old code raised AttributeError that was silently swallowed,
366
+ # leaving the v3.4.5 migration (access_count_30d) permanently unapplied.
367
+ with self._db.raw_connection() as conn:
368
+ if not schema_version_applied(conn):
369
+ result = apply_migration(conn)
370
+ if result.get("errors"):
371
+ logger.warning("Schema v3.4.5 had errors: %s", result["errors"])
368
372
  except ImportError:
369
373
  logger.debug("schema_v345 not found — skipping")
370
374
  except Exception as exc:
@@ -129,6 +129,79 @@ class ChannelWeights:
129
129
  }
130
130
 
131
131
 
132
+ # ---------------------------------------------------------------------------
133
+ # Scope Weights
134
+ # ---------------------------------------------------------------------------
135
+
136
+ @dataclass
137
+ class ScopeWeights:
138
+ """RRF fusion weights for multi-scope retrieval.
139
+
140
+ Personal scope has highest weight (most relevant to current profile).
141
+ Shared scope has medium weight (team/group memories).
142
+ Global scope has lowest weight (public/common knowledge).
143
+ """
144
+
145
+ personal: float = 1.0
146
+ shared: float = 0.7
147
+ global_: float = 0.5 # trailing underscore avoids Python keyword
148
+
149
+ def __post_init__(self) -> None:
150
+ for name in ("personal", "shared", "global_"):
151
+ val = getattr(self, name)
152
+ if val < 0:
153
+ raise ValueError(f"ScopeWeights values must be non-negative, got {name}={val}")
154
+
155
+ def as_dict(self) -> dict[str, float]:
156
+ return {"personal": self.personal, "shared": self.shared, "global": self.global_}
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Scope Config (multi-scope memory behaviour defaults)
161
+ # ---------------------------------------------------------------------------
162
+
163
+ _VALID_SCOPES = ("personal", "shared", "global")
164
+
165
+
166
+ @dataclass
167
+ class ScopeConfig:
168
+ """User-facing defaults for multi-scope (shared) memory.
169
+
170
+ SHARED MEMORY IS OPT-IN — NOT a default feature (v3.6.15 product decision).
171
+ The defaults below make a fresh / unconfigured install behave EXACTLY like
172
+ 3.6.14: every write is ``personal`` and recall returns only this profile's
173
+ own facts. Another profile's ``global``/``shared`` facts never leak into
174
+ recall until the user explicitly turns sharing on.
175
+
176
+ - ``default_scope='personal'`` → writes stay private by default;
177
+ - ``recall_include_global=False`` → don't surface other profiles' global;
178
+ - ``recall_include_shared=False`` → don't surface facts shared *to* me.
179
+
180
+ Turning it on is a deliberate act, done either per-call (``--scope`` /
181
+ ``--include-global`` / MCP args) or persistently by editing config.json /
182
+ mode_a|b|c.json (the installer can also write the choice). The CLI/MCP
183
+ boundary passes ``None`` ("not specified") so the engine resolves these
184
+ config values as the effective default.
185
+ """
186
+
187
+ default_scope: str = "personal" # scope assigned to new memories
188
+ recall_include_global: bool = False # surface scope='global' facts in recall
189
+ recall_include_shared: bool = False # surface scope='shared' facts in recall
190
+
191
+ def __post_init__(self) -> None:
192
+ if self.default_scope not in _VALID_SCOPES:
193
+ raise ValueError(
194
+ f"default_scope must be one of {_VALID_SCOPES}, got {self.default_scope!r}"
195
+ )
196
+
197
+ def as_dict(self) -> dict:
198
+ return {
199
+ "default_scope": self.default_scope,
200
+ "recall_include_global": self.recall_include_global,
201
+ "recall_include_shared": self.recall_include_shared,
202
+ }
203
+
204
+
132
205
  # ---------------------------------------------------------------------------
133
206
  # Encoding Config
134
207
  # ---------------------------------------------------------------------------
@@ -702,6 +775,8 @@ class SLMConfig:
702
775
  embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
703
776
  llm: LLMConfig = field(default_factory=LLMConfig)
704
777
  channel_weights: ChannelWeights = field(default_factory=ChannelWeights)
778
+ scope_weights: ScopeWeights = field(default_factory=ScopeWeights)
779
+ scope: ScopeConfig = field(default_factory=ScopeConfig)
705
780
  encoding: EncodingConfig = field(default_factory=EncodingConfig)
706
781
  retrieval: RetrievalConfig = field(default_factory=RetrievalConfig)
707
782
  math: MathConfig = field(default_factory=MathConfig)
@@ -860,6 +935,36 @@ class SLMConfig:
860
935
  prestage_max_response_bytes=int(inj.get("prestage_max_response_bytes", 64 * 1024)),
861
936
  )
862
937
 
938
+ # Multi-scope memory: scope weights
939
+ sw = data.get("scope_weights", {})
940
+ if sw:
941
+ # v3.6.15: a malformed value (e.g. negative weight) must NOT brick
942
+ # every `slm` command via an uncaught ValueError out of load().
943
+ # Fall back to defaults and warn — the config is recoverable.
944
+ try:
945
+ config.scope_weights = ScopeWeights(**{
946
+ k: v for k, v in sw.items()
947
+ if k in ScopeWeights.__dataclass_fields__
948
+ })
949
+ except (ValueError, TypeError) as exc:
950
+ logger.warning(
951
+ "Ignoring invalid scope_weights in config (%s) — using defaults", exc
952
+ )
953
+
954
+ # Multi-scope memory: behaviour defaults (default scope + recall visibility)
955
+ sc = data.get("scope", {})
956
+ if sc:
957
+ # Same guard: a typo'd default_scope must not crash the whole CLI.
958
+ try:
959
+ config.scope = ScopeConfig(**{
960
+ k: v for k, v in sc.items()
961
+ if k in ScopeConfig.__dataclass_fields__
962
+ })
963
+ except (ValueError, TypeError) as exc:
964
+ logger.warning(
965
+ "Ignoring invalid scope config (%s) — using shared-off defaults", exc
966
+ )
967
+
863
968
  return config
864
969
 
865
970
  def save(
@@ -952,6 +1057,16 @@ class SLMConfig:
952
1057
  "prestage_max_response_bytes": self.injection.prestage_max_response_bytes,
953
1058
  }
954
1059
 
1060
+ # Multi-scope memory: scope weights
1061
+ data["scope_weights"] = {
1062
+ "personal": self.scope_weights.personal,
1063
+ "shared": self.scope_weights.shared,
1064
+ "global_": self.scope_weights.global_,
1065
+ }
1066
+
1067
+ # Multi-scope memory: behaviour defaults
1068
+ data["scope"] = self.scope.as_dict()
1069
+
955
1070
  # Preserve existing V3.3 config sections that aren't in for_mode()
956
1071
  for key in ("forgetting", "quantization", "sagq", "embedding_signature", "auto_invoke"):
957
1072
  if key in existing:
@@ -164,6 +164,25 @@ class MemoryEngine:
164
164
  except Exception as exc:
165
165
  logger.warning("V3.4.6 schema migration failed: %s", exc)
166
166
 
167
+ # v3.6.15: apply ALL pending migrations — including DEFERRED ones like
168
+ # M016 (scope/shared_with columns) — for DIRECT-engine usage: `slm
169
+ # remember --sync`, the Python API, and LangChain/CrewAI integrations.
170
+ # Previously only the daemon lifespan ran apply_deferred, so an existing
171
+ # pre-3.6.15 database used WITHOUT the daemon hit
172
+ # "table memories has no column named scope" on the first scoped write.
173
+ # Idempotent (skips applied migrations) + non-fatal; mirrors the daemon.
174
+ try:
175
+ from superlocalmemory.storage.migration_runner import (
176
+ apply_all, apply_deferred,
177
+ )
178
+ _base = self._config.base_dir
179
+ _learning_db = _base / "learning.db"
180
+ _memory_db = self._db.db_path
181
+ apply_all(_learning_db, _memory_db)
182
+ apply_deferred(_learning_db, _memory_db)
183
+ except Exception as exc:
184
+ logger.warning("v3.6.15 deferred migration apply failed: %s", exc)
185
+
167
186
  # V3.4.7: Apply "Learning Brain" schema (tool_events, behavioral_assertions)
168
187
  try:
169
188
  from superlocalmemory.storage.schema_v347 import apply_v347_schema
@@ -332,7 +351,25 @@ class MemoryEngine:
332
351
  logger.info("Processing %d pending memories from async store", len(pending))
333
352
  for item in pending:
334
353
  try:
335
- self.store(item["content"])
354
+ # v3.6.15 multi-scope: the pending row carries a metadata JSON
355
+ # blob that may hold scope/shared_with (written by the async
356
+ # /remember path). Replay them so a queued ``--scope global``
357
+ # write lands as global, not silently downgraded to personal.
358
+ import json as _json
359
+ meta = item.get("metadata")
360
+ if isinstance(meta, str):
361
+ try:
362
+ meta = _json.loads(meta) if meta else {}
363
+ except (ValueError, TypeError):
364
+ meta = {}
365
+ if not isinstance(meta, dict):
366
+ meta = {}
367
+ _scope = meta.get("scope") or "personal"
368
+ _shared = meta.get("shared_with")
369
+ self.store(
370
+ item["content"], metadata=meta or None,
371
+ scope=_scope, shared_with=_shared,
372
+ )
336
373
  mark_done(item["id"], base_dir)
337
374
  except Exception as exc:
338
375
  logger.warning("Pending memory %d failed: %s", item["id"], exc)
@@ -348,8 +385,15 @@ class MemoryEngine:
348
385
  speaker: str = "",
349
386
  role: str = "user",
350
387
  metadata: dict[str, Any] | None = None,
388
+ *,
389
+ scope: str = "personal",
390
+ shared_with: list[str] | None = None,
351
391
  ) -> list[str]:
352
- """Store content and extract structured facts. Returns fact_ids."""
392
+ """Store content and extract structured facts. Returns fact_ids.
393
+
394
+ Multi-scope: ``scope`` sets the visibility (personal/shared/global).
395
+ ``shared_with`` is a list of profile_ids for shared scope.
396
+ """
353
397
  self._require_full("store")
354
398
  self._ensure_init()
355
399
 
@@ -358,6 +402,7 @@ class MemoryEngine:
358
402
  content, self._profile_id,
359
403
  session_id=session_id, session_date=session_date,
360
404
  speaker=speaker, role=role, metadata=metadata,
405
+ scope=scope, shared_with=shared_with,
361
406
  config=self._config, db=self._db,
362
407
  embedder=self._embedder,
363
408
  fact_extractor=self._fact_extractor,
@@ -397,7 +442,10 @@ class MemoryEngine:
397
442
  vector_store=self._vector_store,
398
443
  )
399
444
 
400
- def store_fast(self, content: str, metadata: dict[str, Any] | None = None) -> list[str]:
445
+ def store_fast(
446
+ self, content: str, metadata: dict[str, Any] | None = None,
447
+ *, scope: str = "personal", shared_with: list[str] | None = None,
448
+ ) -> list[str]:
401
449
  """v3.5.5 WRITE-THROUGH: synchronous verbatim insert for IMMEDIATE recall.
402
450
 
403
451
  Full ``store()`` blocks 30-180s on LLM fact-extraction + Ollama embedding
@@ -455,6 +503,7 @@ class MemoryEngine:
455
503
  session_date=now[:10],
456
504
  session_id=(metadata or {}).get("session_id", ""),
457
505
  metadata=metadata or {},
506
+ scope=scope, shared_with=shared_with,
458
507
  )
459
508
  self._db.store_memory(record)
460
509
  # Lightweight regex entities (matches store_pipeline verbatim path) so
@@ -485,6 +534,7 @@ class MemoryEngine:
485
534
  observation_date=now[:10], confidence=0.7, importance=0.5,
486
535
  embedding=emb, fisher_mean=fmean, fisher_variance=fvar,
487
536
  created_at=now,
537
+ scope=scope, shared_with=shared_with,
488
538
  )
489
539
  self._db.store_fact(fact) # FTS5 trigger → immediately BM25-recallable
490
540
  # Upsert to vector store so the semantic channel finds it now.
@@ -511,6 +561,9 @@ class MemoryEngine:
511
561
  agent_id: str = "unknown",
512
562
  session_id: str | None = None,
513
563
  fast: bool = False,
564
+ *,
565
+ include_global: bool | None = None,
566
+ include_shared: bool | None = None,
514
567
  ) -> RecallResponse:
515
568
  """Recall relevant facts for a query.
516
569
 
@@ -526,10 +579,26 @@ class MemoryEngine:
526
579
  neighbor-cache fix; fast=True is slower than fast=False and reduces
527
580
  recall quality. The parameter is accepted for backward compatibility
528
581
  but is silently treated as False.
582
+
583
+ Multi-scope: ``include_global`` / ``include_shared`` control which
584
+ scopes participate in retrieval. ``None`` (the default) means "use the
585
+ configured ScopeConfig default", which ships OFF — shared memory is
586
+ opt-in (v3.6.15). Personal facts are ALWAYS returned regardless, so a
587
+ config of False reproduces 3.6.14 pure-isolation behaviour exactly.
588
+ This is the single policy chokepoint: every recall path (CLI, MCP,
589
+ daemon HTTP, in-process adapter) flows through here, so a caller that
590
+ forgets to thread the flag still gets the safe configured default.
529
591
  """
530
592
  self._require_full("recall")
531
593
  self._ensure_init()
532
594
 
595
+ # Resolve None → configured ScopeConfig default (shared-off by default).
596
+ _scope_cfg = getattr(self._config, "scope", None)
597
+ if include_global is None:
598
+ include_global = bool(getattr(_scope_cfg, "recall_include_global", False))
599
+ if include_shared is None:
600
+ include_shared = bool(getattr(_scope_cfg, "recall_include_shared", False))
601
+
533
602
  if fast:
534
603
  logger.warning(
535
604
  "fast=True is deprecated (v3.6.9): SpreadingActivation now "
@@ -552,6 +621,8 @@ class MemoryEngine:
552
621
  access_log=self._access_log,
553
622
  auto_linker=self._auto_linker,
554
623
  fast=fast,
624
+ include_global=include_global,
625
+ include_shared=include_shared,
555
626
  )
556
627
 
557
628
  # S9-DASH-02: enqueue for pending_outcomes. Non-blocking; errors
@@ -190,7 +190,8 @@ def _consolidate_cluster(
190
190
  # Load fact contents including canonical_entities_json
191
191
  placeholders = ",".join("?" * len(fact_ids))
192
192
  facts = c.execute(
193
- f"SELECT fact_id, content, confidence, created_at, canonical_entities_json "
193
+ f"SELECT fact_id, content, confidence, created_at, canonical_entities_json, "
194
+ f"scope, shared_with "
194
195
  f"FROM atomic_facts "
195
196
  f"WHERE fact_id IN ({placeholders}) ORDER BY created_at",
196
197
  fact_ids,
@@ -215,6 +216,21 @@ def _consolidate_cluster(
215
216
  now = datetime.now(timezone.utc).isoformat()
216
217
  avg_confidence = sum(f["confidence"] or 0.5 for f in facts) / len(facts)
217
218
 
219
+ # v3.6.15 multi-scope: a summary must never be MORE visible than its
220
+ # sources, or it would leak a private fact into a shared/global summary.
221
+ # Preserve scope only when the whole cluster agrees; any mix (or shared
222
+ # facts with differing targets) falls back to 'personal' — the most
223
+ # restrictive scope. All-personal clusters (the common case) are
224
+ # unchanged. shared_with is preserved only for a uniform shared cluster.
225
+ _src_scopes = {(f["scope"] or "personal") for f in facts}
226
+ _src_shared = {f["shared_with"] for f in facts}
227
+ if _src_scopes == {"global"}:
228
+ _sum_scope, _sum_shared = "global", None
229
+ elif _src_scopes == {"shared"} and len(_src_shared) == 1:
230
+ _sum_scope, _sum_shared = "shared", facts[0]["shared_with"]
231
+ else:
232
+ _sum_scope, _sum_shared = "personal", None
233
+
218
234
  # Collect entities from ALL source facts (already in the SELECT)
219
235
  all_entities = set()
220
236
  raw_entities = set()
@@ -253,13 +269,14 @@ def _consolidate_cluster(
253
269
  (fact_id, memory_id, profile_id, content, fact_type,
254
270
  entities_json, canonical_entities_json,
255
271
  confidence, importance, evidence_count, access_count,
256
- created_at, lifecycle)
257
- VALUES (?, '', ?, ?, 'semantic', ?, ?, ?, 0.8, ?, 0, ?, 'active')
272
+ created_at, lifecycle, scope, shared_with)
273
+ VALUES (?, '', ?, ?, 'semantic', ?, ?, ?, 0.8, ?, 0, ?, 'active', ?, ?)
258
274
  """, (
259
275
  new_fact_id, profile_id, summary,
260
276
  json.dumps(list(all_entities)),
261
277
  json.dumps(list(all_entities)),
262
278
  round(avg_confidence, 3), len(facts), now,
279
+ _sum_scope, _sum_shared,
263
280
  ))
264
281
 
265
282
  # Record the consolidation
@@ -64,6 +64,10 @@ def is_pid_alive(pid: int) -> bool:
64
64
  try:
65
65
  os.kill(pid, 0)
66
66
  return True
67
+ except ProcessLookupError:
68
+ return False # ESRCH — no such process
69
+ except PermissionError:
70
+ return True # EPERM — process EXISTS, we just can't signal it
67
71
  except OSError:
68
72
  return False
69
73
  try:
@@ -73,6 +77,10 @@ def is_pid_alive(pid: int) -> bool:
73
77
  try:
74
78
  os.kill(pid, 0)
75
79
  return True
80
+ except ProcessLookupError:
81
+ return False # ESRCH — no such process
82
+ except PermissionError:
83
+ return True # EPERM — process EXISTS, we just can't signal it
76
84
  except OSError:
77
85
  return False
78
86