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
@@ -24,6 +24,8 @@ _os.environ.setdefault('TORCH_DEVICE', 'cpu')
24
24
  import argparse
25
25
  import sys
26
26
 
27
+ from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
28
+
27
29
  _HELP_EPILOG = """\
28
30
  operating modes:
29
31
  Mode A Local Guardian — Zero cloud, zero LLM. All processing stays on
@@ -76,6 +78,17 @@ def main() -> None:
76
78
  handle_hook(sys.argv[2])
77
79
  return
78
80
 
81
+ # WP-07: lazy first-run init — runs after hook/mcp fast-paths so stdout
82
+ # is never polluted on those paths (CRIT-3, MCP JSON-RPC purity).
83
+ # Guarded: any failure must not crash the CLI (AC4).
84
+ _is_mcp_cmd = len(sys.argv) >= 2 and sys.argv[1] == "mcp"
85
+ if not _is_mcp_cmd:
86
+ try:
87
+ from superlocalmemory.cli._lazy_init import _ensure_initialized
88
+ _ensure_initialized()
89
+ except Exception:
90
+ pass
91
+
79
92
  from superlocalmemory.cli.json_output import _get_version
80
93
  _ver = _get_version()
81
94
 
@@ -101,8 +114,9 @@ def main() -> None:
101
114
  from superlocalmemory.migrations.v3_4_25_to_v3_4_26 import (
102
115
  migrate_if_safe as _migrate_if_safe,
103
116
  )
104
- _data = _P(_os.environ.get("SLM_DATA_DIR")
105
- or _P.home() / ".superlocalmemory")
117
+ # WP-07: route through slm_home() so all 3 env aliases are honoured.
118
+ from superlocalmemory.cli._lazy_init import slm_home as _slm_home
119
+ _data = _slm_home()
106
120
  _res = _migrate_if_safe(_data)
107
121
  if _res.get("status") == "deferred":
108
122
  print(
@@ -140,6 +154,11 @@ def main() -> None:
140
154
  "--gate", action="store_true",
141
155
  help="Enable PreToolUse gate (experimental — blocks tools until session_init)",
142
156
  )
157
+ # WP-07: non-interactive auto setup (pip post-install, CI, scripts).
158
+ init_p.add_argument(
159
+ "--auto", action="store_true",
160
+ help="Non-interactive setup: mode A + hooks (no TTY required, for CI/scripts)",
161
+ )
143
162
 
144
163
  setup_p = sub.add_parser("setup", help="Interactive first-time setup wizard")
145
164
  setup_p.add_argument(
@@ -159,11 +178,32 @@ def main() -> None:
159
178
  )
160
179
 
161
180
  connect_p = sub.add_parser("connect", help="Auto-configure IDE integrations (17+ IDEs)")
162
- connect_p.add_argument("ide", nargs="?", help="Specific IDE to configure")
181
+ connect_p.add_argument("ide", nargs="?", help="Specific IDE to configure (e.g. cursor, codex, continue)")
163
182
  connect_p.add_argument(
164
183
  "--list", action="store_true", help="List all supported IDEs",
165
184
  )
166
185
  connect_p.add_argument("--json", action="store_true", help="Output structured JSON (agent-native)")
186
+ # WP-08 CRIT-1: declare missing flags so cmd_connect can read them without getattr fallback
187
+ connect_p.add_argument(
188
+ "--here", action="store_true", default=False,
189
+ help="Write config relative to current working directory (project scope)",
190
+ )
191
+ connect_p.add_argument(
192
+ "--cross-platform", action="store_true", dest="cross_platform", default=False,
193
+ help="Use LLD-05 cross-platform adapter orchestrator",
194
+ )
195
+ connect_p.add_argument(
196
+ "--disable", metavar="ADAPTER", default=None,
197
+ help="Disable a specific cross-platform adapter by name",
198
+ )
199
+ connect_p.add_argument(
200
+ "--profile", metavar="PROFILE", default=None,
201
+ help="Inject SLM_MCP_PROFILE env var into the MCP server block (WP-01)",
202
+ )
203
+ connect_p.add_argument(
204
+ "--dry-run", action="store_true", dest="dry_run", default=False,
205
+ help="Show what would be written without making changes",
206
+ )
167
207
 
168
208
  migrate_p = sub.add_parser("migrate", help="Migrate data from V2 to V3 schema")
169
209
  migrate_p.add_argument(
@@ -195,12 +235,24 @@ def main() -> None:
195
235
  "--sync", dest="sync_mode", action="store_true",
196
236
  help="Wait for completion (default: async background processing)",
197
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
+ )
198
247
 
199
248
  # v3.6.12 (parity-3): `search` is an alias of `recall` so the CLI has the
200
249
  # same search verb the MCP exposes (handlers dict maps both to cmd_recall).
201
250
  recall_p = sub.add_parser("recall", aliases=["search"], help="Semantic search with 4-channel retrieval")
202
251
  recall_p.add_argument("query", help="Search query")
203
- recall_p.add_argument("--limit", type=int, default=10, help="Max results (default 10)")
252
+ recall_p.add_argument(
253
+ "--limit", type=int, default=CANONICAL_RECALL_LIMIT,
254
+ help=f"Max results (default {CANONICAL_RECALL_LIMIT})",
255
+ )
204
256
  recall_p.add_argument("--json", action="store_true", help="Output structured JSON (agent-native)")
205
257
  recall_p.add_argument(
206
258
  "--fast", action="store_true",
@@ -208,6 +260,26 @@ def main() -> None:
208
260
  "Other 4 channels (semantic, lexical, temporal, structural) still run. "
209
261
  "Use when you need recall before a tool call (e.g. before WebSearch).",
210
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
+ )
211
283
 
212
284
  forget_p = sub.add_parser("forget", help="Delete memories matching a query (fuzzy)")
213
285
  forget_p.add_argument("query", help="Query to match for deletion")
@@ -337,6 +409,22 @@ def main() -> None:
337
409
  )
338
410
  ctx_p.add_argument("--json", action="store_true", help="Output structured JSON (agent-native)")
339
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
+
340
428
  obs_p = sub.add_parser("observe", help="Auto-capture content (pipe or argument)")
341
429
  obs_p.add_argument("content", nargs="?", default="", help="Content to evaluate")
342
430
 
@@ -28,7 +28,17 @@ from pathlib import Path
28
28
  # Constants
29
29
  # ---------------------------------------------------------------------------
30
30
 
31
- _SLM_HOME = Path(os.environ.get("SL_MEMORY_PATH", Path.home() / ".superlocalmemory"))
31
+ # WP-07: resolve via slm_home() so all 3 env aliases are honoured.
32
+ # Fallback keeps stdlib-only path if the import fails during early bootstrap.
33
+ def _resolve_slm_home() -> Path:
34
+ try:
35
+ from superlocalmemory.cli._lazy_init import slm_home
36
+ return slm_home()
37
+ except Exception:
38
+ return Path(os.environ.get("SL_MEMORY_PATH", "") or Path.home() / ".superlocalmemory")
39
+
40
+
41
+ _SLM_HOME = _resolve_slm_home()
32
42
  _SETUP_MARKER = _SLM_HOME / ".setup-complete"
33
43
  _EMBED_MODEL = "nomic-ai/nomic-embed-text-v1.5"
34
44
  _RERANKER_MODEL = "cross-encoder/ms-marco-MiniLM-L-12-v2"
@@ -369,6 +379,33 @@ def run_wizard(auto: bool = False) -> None:
369
379
  mode_map = {"a": Mode.A, "b": Mode.B, "c": Mode.C}
370
380
  config = SLMConfig.for_mode(mode_map[choice])
371
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
+
372
409
  if choice == "b":
373
410
  print()
374
411
  if shutil.which("ollama"):
@@ -703,14 +740,18 @@ def check_first_use(command: str) -> None:
703
740
  if is_setup_complete():
704
741
  return
705
742
 
706
- # Non-interactive: use defaults silently, don't block the command
743
+ # Non-interactive: use defaults silently, don't block the command.
744
+ # CRIT-1: only save config when it does NOT already exist — lazy-init may
745
+ # have already written a valid config.json; overwriting it here would clobber
746
+ # any lazy-init content (e.g. a pre-existing mode-A skeleton).
707
747
  if not is_interactive():
708
- # Just create config with defaults and mark complete
709
748
  try:
710
- from superlocalmemory.core.config import SLMConfig
749
+ from superlocalmemory.core.config import SLMConfig, DEFAULT_BASE_DIR
711
750
  from superlocalmemory.storage.models import Mode
712
- config = SLMConfig.for_mode(Mode.A)
713
- config.save(mode_change=True)
751
+ config_path = DEFAULT_BASE_DIR / "config.json"
752
+ if not config_path.exists():
753
+ cfg = SLMConfig.for_mode(Mode.A)
754
+ cfg.save(mode_change=True)
714
755
  _mark_complete()
715
756
  except Exception:
716
757
  pass
@@ -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:
@@ -21,6 +21,16 @@ logger = logging.getLogger(__name__)
21
21
  from superlocalmemory.storage.models import Mode
22
22
 
23
23
 
24
+ # ---------------------------------------------------------------------------
25
+ # Canonical limits (WP-02 — single source of truth across all surfaces)
26
+ # ---------------------------------------------------------------------------
27
+
28
+ #: Default number of results returned by recall across MCP, CLI, daemon, and
29
+ #: engine. All surfaces bind their ``limit`` defaults to this constant so a
30
+ #: single change is sufficient to keep the full stack in sync.
31
+ CANONICAL_RECALL_LIMIT: int = 20
32
+
33
+
24
34
  # ---------------------------------------------------------------------------
25
35
  # Default Paths
26
36
  # ---------------------------------------------------------------------------
@@ -119,6 +129,79 @@ class ChannelWeights:
119
129
  }
120
130
 
121
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
+
122
205
  # ---------------------------------------------------------------------------
123
206
  # Encoding Config
124
207
  # ---------------------------------------------------------------------------
@@ -692,6 +775,8 @@ class SLMConfig:
692
775
  embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
693
776
  llm: LLMConfig = field(default_factory=LLMConfig)
694
777
  channel_weights: ChannelWeights = field(default_factory=ChannelWeights)
778
+ scope_weights: ScopeWeights = field(default_factory=ScopeWeights)
779
+ scope: ScopeConfig = field(default_factory=ScopeConfig)
695
780
  encoding: EncodingConfig = field(default_factory=EncodingConfig)
696
781
  retrieval: RetrievalConfig = field(default_factory=RetrievalConfig)
697
782
  math: MathConfig = field(default_factory=MathConfig)
@@ -739,17 +824,32 @@ class SLMConfig:
739
824
  @classmethod
740
825
  def load(cls, config_path: Path | None = None) -> SLMConfig:
741
826
  """Load config from JSON file. Returns default Mode A if file doesn't exist."""
742
- path = config_path or (DEFAULT_BASE_DIR / "config.json")
827
+ # WP-07: resolve base dir through slm_home() at call time so all 3 env
828
+ # aliases (SLM_DATA_DIR → SL_MEMORY_PATH → SLM_HOME) are honoured.
829
+ try:
830
+ from superlocalmemory.cli._lazy_init import slm_home as _slm_home
831
+ _runtime_base = _slm_home()
832
+ except Exception:
833
+ _runtime_base = DEFAULT_BASE_DIR
834
+ path = config_path or (_runtime_base / "config.json")
743
835
  if not path.exists():
744
836
  return cls.for_mode(Mode.A)
745
837
  import json
746
- data = json.loads(path.read_text())
838
+ try:
839
+ data = json.loads(path.read_text())
840
+ except (json.JSONDecodeError, OSError) as exc:
841
+ # An already-corrupt/truncated config.json must NOT brick every `slm`
842
+ # call — degrade to the Mode-A default and warn rather than raise.
843
+ logger.warning(
844
+ "config.json unreadable/corrupt (%s) — using Mode A default", exc
845
+ )
846
+ return cls.for_mode(Mode.A)
747
847
  mode = Mode(data.get("mode", "a"))
748
848
  llm_data = data.get("llm", {})
749
849
  emb_data = data.get("embedding", {})
750
850
  # V3.5.9: read base_dir before constructing config so for_mode() builds
751
851
  # db_path from the user's directory, not DEFAULT_BASE_DIR.
752
- raw_base_dir = Path(data.get("base_dir", str(DEFAULT_BASE_DIR)))
852
+ raw_base_dir = Path(data.get("base_dir", str(_runtime_base)))
753
853
  config = cls.for_mode(
754
854
  mode,
755
855
  llm_provider=llm_data.get("provider", ""),
@@ -835,6 +935,36 @@ class SLMConfig:
835
935
  prestage_max_response_bytes=int(inj.get("prestage_max_response_bytes", 64 * 1024)),
836
936
  )
837
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
+
838
968
  return config
839
969
 
840
970
  def save(
@@ -927,12 +1057,27 @@ class SLMConfig:
927
1057
  "prestage_max_response_bytes": self.injection.prestage_max_response_bytes,
928
1058
  }
929
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
+
930
1070
  # Preserve existing V3.3 config sections that aren't in for_mode()
931
1071
  for key in ("forgetting", "quantization", "sagq", "embedding_signature", "auto_invoke"):
932
1072
  if key in existing:
933
1073
  data[key] = existing[key]
934
1074
 
935
- path.write_text(json.dumps(data, indent=2))
1075
+ # Atomic write: a crash mid-write must NOT leave a truncated/corrupt
1076
+ # config.json (which would make every subsequent `slm` call fail to load).
1077
+ import os as _os
1078
+ _tmp = path.with_suffix(path.suffix + ".tmp")
1079
+ _tmp.write_text(json.dumps(data, indent=2))
1080
+ _os.replace(_tmp, path)
936
1081
 
937
1082
  @staticmethod
938
1083
  def provider_presets() -> dict[str, dict[str, str]]:
@@ -987,7 +1132,15 @@ class SLMConfig:
987
1132
  embedding_dimension: int = 0,
988
1133
  ) -> SLMConfig:
989
1134
  """Create config with mode-appropriate defaults."""
990
- _base = base_dir or DEFAULT_BASE_DIR
1135
+ # WP-07: resolve base dir via slm_home() at call time when not explicit.
1136
+ if base_dir is None:
1137
+ try:
1138
+ from superlocalmemory.cli._lazy_init import slm_home as _slm_home
1139
+ _base = _slm_home()
1140
+ except Exception:
1141
+ _base = DEFAULT_BASE_DIR
1142
+ else:
1143
+ _base = base_dir
991
1144
 
992
1145
  if mode == Mode.A:
993
1146
  # V3.4.24: If user chose "openai" provider, honour their custom
@@ -1130,7 +1283,15 @@ class SLMConfig:
1130
1283
 
1131
1284
  Returns ``\"b\"`` (the default) if the file doesn't exist.
1132
1285
  """
1133
- _base = base_dir or DEFAULT_BASE_DIR
1286
+ # WP-07: resolve via slm_home() at call time when base_dir not explicit.
1287
+ if base_dir is None:
1288
+ try:
1289
+ from superlocalmemory.cli._lazy_init import slm_home as _slm_home
1290
+ _base = _slm_home()
1291
+ except Exception:
1292
+ _base = DEFAULT_BASE_DIR
1293
+ else:
1294
+ _base = base_dir
1134
1295
  _f = _base / CURRENT_MODE_FILE
1135
1296
  try:
1136
1297
  return _f.read_text(encoding="utf-8").strip().lower() or "b"
@@ -1140,7 +1301,15 @@ class SLMConfig:
1140
1301
  @staticmethod
1141
1302
  def write_current_mode(mode: str, base_dir: Path | None = None) -> None:
1142
1303
  """Write the current mode letter to ``current_mode``."""
1143
- _base = base_dir or DEFAULT_BASE_DIR
1304
+ # WP-07: resolve via slm_home() at call time when base_dir not explicit.
1305
+ if base_dir is None:
1306
+ try:
1307
+ from superlocalmemory.cli._lazy_init import slm_home as _slm_home
1308
+ _base = _slm_home()
1309
+ except Exception:
1310
+ _base = DEFAULT_BASE_DIR
1311
+ else:
1312
+ _base = base_dir
1144
1313
  _base.mkdir(parents=True, exist_ok=True)
1145
1314
  (_base / CURRENT_MODE_FILE).write_text(
1146
1315
  mode.lower().strip(), encoding="utf-8",
@@ -1175,7 +1344,15 @@ class SLMConfig:
1175
1344
  import dataclasses
1176
1345
 
1177
1346
  from superlocalmemory.storage.models import Mode as _M
1178
- _base = base_dir or DEFAULT_BASE_DIR
1347
+ # WP-07: resolve via slm_home() at call time when base_dir not explicit.
1348
+ if base_dir is None:
1349
+ try:
1350
+ from superlocalmemory.cli._lazy_init import slm_home as _slm_home
1351
+ _base = _slm_home()
1352
+ except Exception:
1353
+ _base = DEFAULT_BASE_DIR
1354
+ else:
1355
+ _base = base_dir
1179
1356
  _base.mkdir(parents=True, exist_ok=True)
1180
1357
 
1181
1358
  old_config = cls.load(_base / "config.json")
@@ -1242,7 +1419,15 @@ class SLMConfig:
1242
1419
  Called on daemon boot. Idempotent — if ``current_mode`` already
1243
1420
  exists, this is a no-op. Returns True if migration was performed.
1244
1421
  """
1245
- _base = base_dir or DEFAULT_BASE_DIR
1422
+ # WP-07: resolve via slm_home() at call time when base_dir not explicit.
1423
+ if base_dir is None:
1424
+ try:
1425
+ from superlocalmemory.cli._lazy_init import slm_home as _slm_home
1426
+ _base = _slm_home()
1427
+ except Exception:
1428
+ _base = DEFAULT_BASE_DIR
1429
+ else:
1430
+ _base = base_dir
1246
1431
  _current = _base / CURRENT_MODE_FILE
1247
1432
  if _current.exists():
1248
1433
  return False # already migrated
@@ -601,8 +601,13 @@ class EmbeddingService:
601
601
  def _cloud_embed_batch(
602
602
  self, texts: list[str], *, max_retries: int = 3,
603
603
  ) -> list[list[float]]:
604
- """Encode via Azure OpenAI embedding API with retry."""
605
- import httpx
604
+ """Encode via Azure OpenAI embedding API with retry.
605
+
606
+ V3.6.14: Reuses self._get_http_client() (shared persistent connection)
607
+ instead of creating a fresh httpx.Client per call/retry. resp.json()
608
+ is now called inside the try block while the response object is still
609
+ in scope, eliminating reliance on httpx body-buffering after close.
610
+ """
606
611
  url = (
607
612
  f"{self._config.api_endpoint.rstrip('/')}/openai/deployments/"
608
613
  f"{self._config.deployment_name}/embeddings"
@@ -613,12 +618,12 @@ class EmbeddingService:
613
618
  "api-key": self._config.api_key,
614
619
  }
615
620
  body = {"input": texts, "model": self._config.deployment_name}
621
+ client = self._get_http_client()
616
622
  last_error: Exception | None = None
617
623
  for attempt in range(max_retries):
618
624
  try:
619
- with httpx.Client(timeout=httpx.Timeout(30.0)) as client:
620
- resp = client.post(url, headers=headers, json=body)
621
- resp.raise_for_status()
625
+ resp = client.post(url, headers=headers, json=body)
626
+ resp.raise_for_status()
622
627
  data = resp.json()
623
628
  results = []
624
629
  for item in sorted(data["data"], key=lambda d: d["index"]):