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.
- package/CHANGELOG.md +28 -0
- package/README.md +5 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +5 -4
- package/plugin/agents/slm-memory-advisor.md +5 -4
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +10 -2
- package/plugin/skills/slm-remember/SKILL.md +14 -2
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/agents/slm-memory-advisor.md +5 -4
- package/plugin-src/agents/slm-optimize-advisor.md +1 -1
- package/plugin-src/commands/slm-optimize.md +1 -1
- package/plugin-src/commands/slm-recall.md +1 -1
- package/plugin-src/commands/slm-remember.md +2 -2
- package/plugin-src/commands/slm-status.md +1 -1
- package/plugin-src/manifest.json +1 -1
- package/plugin-src/requirements.txt +1 -1
- package/plugin-src/rules/AGENTS.md +5 -4
- package/plugin-src/rules/CLAUDE.md.fragment +5 -4
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +10 -2
- package/plugin-src/skills/slm-remember/SKILL.md +14 -2
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/scripts/build-plugin.js +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +91 -2
- package/src/superlocalmemory/cli/main.py +45 -0
- package/src/superlocalmemory/cli/setup_wizard.py +27 -0
- package/src/superlocalmemory/core/backend_orchestrator.py +12 -8
- package/src/superlocalmemory/core/config.py +115 -0
- package/src/superlocalmemory/core/engine.py +74 -3
- package/src/superlocalmemory/core/fact_consolidator.py +20 -3
- package/src/superlocalmemory/core/platform_utils.py +8 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -0
- package/src/superlocalmemory/core/recall_worker.py +7 -0
- package/src/superlocalmemory/core/store_pipeline.py +23 -1
- package/src/superlocalmemory/core/worker_pool.py +14 -2
- package/src/superlocalmemory/hooks/session_registry.py +8 -4
- package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -2
- package/src/superlocalmemory/mcp/_pool_adapter.py +15 -6
- package/src/superlocalmemory/mcp/tools_core.py +25 -0
- package/src/superlocalmemory/mcp/tools_v3.py +6 -1
- package/src/superlocalmemory/mcp/tools_v33.py +8 -4
- package/src/superlocalmemory/retrieval/bm25_channel.py +12 -2
- package/src/superlocalmemory/retrieval/engine.py +36 -3
- package/src/superlocalmemory/retrieval/entity_channel.py +5 -5
- package/src/superlocalmemory/retrieval/hopfield_channel.py +10 -2
- package/src/superlocalmemory/retrieval/semantic_channel.py +10 -2
- package/src/superlocalmemory/server/unified_daemon.py +132 -10
- package/src/superlocalmemory/storage/database.py +215 -43
- package/src/superlocalmemory/storage/migration_runner.py +17 -1
- package/src/superlocalmemory/storage/migrations/M016_add_scope_support.py +120 -0
- package/src/superlocalmemory/storage/models.py +10 -0
- package/src/superlocalmemory/storage/schema.py +15 -10
- package/src/superlocalmemory.egg-info/PKG-INFO +6 -3
- 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(
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|