Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/superlocalmemory/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,8 @@ def cmd_remember(args: Namespace) -> None:

use_json = getattr(args, 'json', False)
sync_mode = getattr(args, 'sync_mode', False)
scope = getattr(args, 'scope', 'personal')
shared_with = getattr(args, 'shared_with', None)

# V3.3.21: Route through daemon for instant remember (no cold start).
# If daemon is running, send request directly (~0.1s).
Expand All @@ -916,6 +918,8 @@ def cmd_remember(args: Namespace) -> None:
result = daemon_request("POST", "/remember", {
"content": args.content,
"tags": args.tags or "",
"scope": scope,
"shared_with": shared_with,
})
if result and "fact_ids" in result:
if use_json:
Expand Down Expand Up @@ -952,7 +956,10 @@ def cmd_remember(args: Namespace) -> None:
engine.initialize()

metadata = {"tags": args.tags} if args.tags else {}
fact_ids = engine.store(args.content, metadata=metadata)
fact_ids = engine.store(
args.content, metadata=metadata,
scope=scope, shared_with=shared_with,
)
except Exception as exc:
if use_json:
from superlocalmemory.cli.json_output import json_print
Expand All @@ -975,6 +982,8 @@ def cmd_remember(args: Namespace) -> None:
def cmd_recall(args: Namespace) -> None:
"""Search memories via the engine — routes through daemon if available."""
use_json = getattr(args, 'json', False)
include_global = getattr(args, 'include_global', True)
include_shared = getattr(args, 'include_shared', True)

# V3.3.21: Route through daemon for instant response (no cold start).
# Falls back to direct engine if daemon not running.
Expand All @@ -988,10 +997,11 @@ def cmd_recall(args: Namespace) -> None:
from urllib.parse import quote
session_id = f"cli:{os.getppid()}"
fast_qs = "&fast=true" if getattr(args, "fast", False) else ""
scope_qs = f"&include_global={str(include_global).lower()}&include_shared={str(include_shared).lower()}"
result = daemon_request(
"GET",
f"/recall?q={quote(args.query)}&limit={args.limit}"
f"&session_id={quote(session_id)}{fast_qs}",
f"&session_id={quote(session_id)}{fast_qs}{scope_qs}",
)
if result and "results" in result:
# Format daemon response same as engine response
Expand Down
24 changes: 24 additions & 0 deletions src/superlocalmemory/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,14 @@ def main() -> None:
"--sync", dest="sync_mode", action="store_true",
help="Wait for completion (default: async background processing)",
)
remember_p.add_argument(
"--scope", default="personal", choices=("personal", "shared", "global"),
help="Memory scope: personal, shared, or global (default: personal)",
)
remember_p.add_argument(
"--shared-with", default=None,
help="Comma-separated profile IDs for shared scope",
)

# v3.6.12 (parity-3): `search` is an alias of `recall` so the CLI has the
# same search verb the MCP exposes (handlers dict maps both to cmd_recall).
Expand All @@ -208,6 +216,22 @@ def main() -> None:
"Other 4 channels (semantic, lexical, temporal, structural) still run. "
"Use when you need recall before a tool call (e.g. before WebSearch).",
)
recall_p.add_argument(
"--include-global", dest="include_global", action="store_true", default=True,
help="Include global-scope facts in retrieval (default: True)",
)
recall_p.add_argument(
"--no-global", dest="include_global", action="store_false",
help="Exclude global-scope facts from retrieval",
)
recall_p.add_argument(
"--include-shared", dest="include_shared", action="store_true", default=True,
help="Include shared-scope facts in retrieval (default: True)",
)
recall_p.add_argument(
"--no-shared", dest="include_shared", action="store_false",
help="Exclude shared-scope facts from retrieval",
)

forget_p = sub.add_parser("forget", help="Delete memories matching a query (fuzzy)")
forget_p.add_argument("query", help="Query to match for deletion")
Expand Down
43 changes: 43 additions & 0 deletions src/superlocalmemory/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,33 @@ def as_dict(self) -> dict[str, float]:
}


# ---------------------------------------------------------------------------
# Scope Weights
# ---------------------------------------------------------------------------

@dataclass
class ScopeWeights:
"""RRF fusion weights for multi-scope retrieval.

Personal scope has highest weight (most relevant to current profile).
Shared scope has medium weight (team/group memories).
Global scope has lowest weight (public/common knowledge).
"""

personal: float = 1.0
shared: float = 0.7
global_: float = 0.5 # trailing underscore avoids Python keyword

def __post_init__(self) -> None:
for name in ("personal", "shared", "global_"):
val = getattr(self, name)
if val < 0:
raise ValueError(f"ScopeWeights values must be non-negative, got {name}={val}")

def as_dict(self) -> dict[str, float]:
return {"personal": self.personal, "shared": self.shared, "global": self.global_}


# ---------------------------------------------------------------------------
# Encoding Config
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -692,6 +719,7 @@ class SLMConfig:
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
llm: LLMConfig = field(default_factory=LLMConfig)
channel_weights: ChannelWeights = field(default_factory=ChannelWeights)
scope_weights: ScopeWeights = field(default_factory=ScopeWeights)
encoding: EncodingConfig = field(default_factory=EncodingConfig)
retrieval: RetrievalConfig = field(default_factory=RetrievalConfig)
math: MathConfig = field(default_factory=MathConfig)
Expand Down Expand Up @@ -835,6 +863,14 @@ def load(cls, config_path: Path | None = None) -> SLMConfig:
prestage_max_response_bytes=int(inj.get("prestage_max_response_bytes", 64 * 1024)),
)

# Multi-scope memory: scope weights
sw = data.get("scope_weights", {})
if sw:
config.scope_weights = ScopeWeights(**{
k: v for k, v in sw.items()
if k in ScopeWeights.__dataclass_fields__
})

return config

def save(
Expand Down Expand Up @@ -927,6 +963,13 @@ def save(
"prestage_max_response_bytes": self.injection.prestage_max_response_bytes,
}

# Multi-scope memory: scope weights
data["scope_weights"] = {
"personal": self.scope_weights.personal,
"shared": self.scope_weights.shared,
"global_": self.scope_weights.global_,
}

# Preserve existing V3.3 config sections that aren't in for_mode()
for key in ("forgetting", "quantization", "sagq", "embedding_signature", "auto_invoke"):
if key in existing:
Expand Down
19 changes: 18 additions & 1 deletion src/superlocalmemory/core/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,15 @@ def store(
speaker: str = "",
role: str = "user",
metadata: dict[str, Any] | None = None,
*,
scope: str = "personal",
shared_with: list[str] | None = None,
) -> list[str]:
"""Store content and extract structured facts. Returns fact_ids."""
"""Store content and extract structured facts. Returns fact_ids.

Multi-scope: ``scope`` sets the visibility (personal/shared/global).
``shared_with`` is a list of profile_ids for shared scope.
"""
self._require_full("store")
self._ensure_init()

Expand All @@ -358,6 +365,7 @@ def store(
content, self._profile_id,
session_id=session_id, session_date=session_date,
speaker=speaker, role=role, metadata=metadata,
scope=scope, shared_with=shared_with,
config=self._config, db=self._db,
embedder=self._embedder,
fact_extractor=self._fact_extractor,
Expand Down Expand Up @@ -511,6 +519,9 @@ def recall(
agent_id: str = "unknown",
session_id: str | None = None,
fast: bool = False,
*,
include_global: bool = True,
include_shared: bool = True,
) -> RecallResponse:
"""Recall relevant facts for a query.

Expand All @@ -526,6 +537,10 @@ def recall(
neighbor-cache fix; fast=True is slower than fast=False and reduces
recall quality. The parameter is accepted for backward compatibility
but is silently treated as False.

Multi-scope: ``include_global`` / ``include_shared`` control which
scopes participate in retrieval. Both default to True (backward
compatible — all existing data is scope='personal').
"""
self._require_full("recall")
self._ensure_init()
Expand All @@ -552,6 +567,8 @@ def recall(
access_log=self._access_log,
auto_linker=self._auto_linker,
fast=fast,
include_global=include_global,
include_shared=include_shared,
)

# S9-DASH-02: enqueue for pending_outcomes. Non-blocking; errors
Expand Down
7 changes: 7 additions & 0 deletions src/superlocalmemory/core/recall_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,9 +587,14 @@ def run_recall(
access_log: Any = None,
auto_linker: Any = None,
fast: bool = False,
include_global: bool = True,
include_shared: bool = True,
) -> RecallResponse:
"""Recall relevant facts for a query.

Multi-scope: ``include_global`` / ``include_shared`` control which
scopes participate in retrieval (passed through to retrieval engine).

Pipeline: retrieval -> agentic sufficiency (if configured) -> post-recall updates.

V3.4.40: ``fast=True`` adds spreading_activation to the per-recall
Expand Down Expand Up @@ -623,6 +628,8 @@ def _mark(_label: str) -> None:
response = retrieval_engine.recall(
query, profile_id, m, limit,
extra_disabled_channels=extra_disabled,
include_global=include_global,
include_shared=include_shared,
)
_mark("retrieval(chan+rerank)")

Expand Down
16 changes: 15 additions & 1 deletion src/superlocalmemory/core/store_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ def enrich_fact(
langevin_position=langevin_pos,
emotional_valence=emotion.valence, emotional_arousal=emotion.arousal,
signal_type=signal, created_at=fact.created_at,
pinned=getattr(fact, 'pinned', False),
scope=getattr(fact, 'scope', 'personal'),
shared_with=getattr(fact, 'shared_with', None),
)


Expand Down Expand Up @@ -146,6 +149,8 @@ def run_store(
role: str = "user",
metadata: dict[str, Any] | None = None,
*,
scope: str = "personal",
shared_with: list[str] | None = None,
config: SLMConfig,
db: DatabaseManager,
embedder: Any,
Expand All @@ -169,7 +174,11 @@ def run_store(
context_generator: Any = None,
consolidation_engine: Any = None,
) -> list[str]:
"""Store content and extract structured facts. Returns fact_ids."""
"""Store content and extract structured facts. Returns fact_ids.

Multi-scope: ``scope`` sets visibility (personal/shared/global).
``shared_with`` is a list of profile_ids for shared scope.
"""
# Pre-operation hooks (trust gate, ABAC, rate limiter)
hook_ctx = {
"operation": "store",
Expand Down Expand Up @@ -203,6 +212,7 @@ def run_store(
profile_id=profile_id, content=content,
session_id=session_id, speaker=speaker, role=role,
session_date=parsed_date, metadata=metadata or {},
scope=scope, shared_with=shared_with,
)
db.store_memory(record)

Expand Down Expand Up @@ -259,6 +269,8 @@ def run_store(
observation_date=parsed_date,
confidence=0.9,
importance=0.5,
scope=scope,
shared_with=shared_with,
)
# Avoid duplicate if extraction already produced the exact same text
extracted_texts = {f.content.strip().lower() for f in facts}
Expand All @@ -280,6 +292,8 @@ def run_store(
observation_date=parsed_date,
confidence=0.7,
importance=0.3,
scope=scope,
shared_with=shared_with,
)]

if not facts:
Expand Down
16 changes: 16 additions & 0 deletions src/superlocalmemory/mcp/tools_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,17 @@ async def remember(
content: str, tags: str = "", project: str = "",
importance: int = 5, session_id: str = "",
agent_id: str = "mcp_client",
scope: str = "personal",
shared_with: str = "",
) -> dict:
"""Store content to memory with intelligent indexing.

Extracts atomic facts, resolves entities, builds graph edges,
and indexes for 4-channel retrieval.

Multi-scope: ``scope`` sets visibility (personal/shared/global).
``shared_with`` is a comma-separated list of profile_ids for
shared scope.
"""
# v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
if agent_id == "mcp_client":
Expand All @@ -120,6 +126,8 @@ async def remember(
"agent_id": agent_id,
"session_id": session_id,
}
# Parse shared_with from comma-separated string
_shared_list = [s.strip() for s in shared_with.split(",") if s.strip()] if shared_with else None
# v3.5.5 WRITE-THROUGH: route through the daemon's /remember, which does
# a synchronous verbatim insert (memory is keyword/BM25-recallable the
# instant this returns) and enqueues async enrichment. This closes the
Expand All @@ -134,6 +142,7 @@ async def remember(
if await _asyncio.to_thread(is_daemon_running):
resp = await _asyncio.to_thread(daemon_request, "POST", "/remember", {
"content": content, "tags": tags, "metadata": meta,
"scope": scope, "shared_with": _shared_list,
})
if resp and (resp.get("fact_ids") is not None or resp.get("ok")):
fids = resp.get("fact_ids") or []
Expand Down Expand Up @@ -166,6 +175,8 @@ async def remember(
async def recall(
query: str, limit: int = 10, agent_id: str = "mcp_client",
session_id: str = "", fast: bool = False,
include_global: bool = True,
include_shared: bool = True,
) -> dict:
"""Search memories by semantic query with 4-channel retrieval, RRF fusion, and reranking.

Expand All @@ -174,6 +185,9 @@ async def recall(
engagement signals to this recall. Claude Code should pass its
``CLAUDE_SESSION_ID``. Omitting it degrades to "no closed-loop
learning for this recall" — the recall itself always works.

Multi-scope: ``include_global`` / ``include_shared`` control
which scopes participate in retrieval.
"""
# v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
if agent_id == "mcp_client":
Expand Down Expand Up @@ -228,6 +242,8 @@ async def recall(
result = await asyncio.to_thread(
pool.recall, query, limit=limit, session_id=effective_sid,
fast=bool(fast),
include_global=include_global,
include_shared=include_shared,
)
if result.get("ok"):
# Record implicit feedback: every returned result is a recall_hit
Expand Down
14 changes: 12 additions & 2 deletions src/superlocalmemory/retrieval/bm25_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,15 @@ def ensure_loaded(self, profile_id: str) -> None:
return

token_map = self._db.get_all_bm25_tokens(profile_id)
_inc_global = getattr(self, 'include_global', True)
_inc_shared = getattr(self, 'include_shared', True)
if not token_map:
# Fallback: tokenize facts directly if no pre-stored tokens
facts = self._db.get_all_facts(profile_id)
facts = self._db.get_all_facts(
profile_id,
include_global=_inc_global,
include_shared=_inc_shared,
)
for fact in facts:
if fact.fact_id in self._fact_id_set:
continue
Expand All @@ -104,7 +110,11 @@ def ensure_loaded(self, profile_id: str) -> None:
# Load raw texts for phrase matching (V3.3.12)
fact_content_map = {}
try:
facts = self._db.get_all_facts(profile_id)
facts = self._db.get_all_facts(
profile_id,
include_global=_inc_global,
include_shared=_inc_shared,
)
fact_content_map = {f.fact_id: f.content for f in facts}
except Exception:
pass
Expand Down
Loading