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
32 changes: 32 additions & 0 deletions src/memos/api/product_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1321,6 +1321,38 @@ class ExistMemCubeIdResponse(BaseResponse[dict[str, bool]]):
"""Response model for checking if mem cube id exists."""


class CreateCubeRequest(BaseRequest):
"""Request model for explicitly creating a mem cube.

Creating a cube up-front is required for multi-tenant / multi-cube
deployments: without an explicit cube registration, ``/product/add``
will succeed (writing embeddings to the vector store) but
``/product/search`` will return empty results because the tree
registry has no entry for the cube. See Issue #1681 for context.
"""

cube_id: str = Field(..., description="Unique identifier for the new cube")
owner_id: str = Field(
...,
description=(
"User ID that owns the cube. Currently used as a marker tag on the cube; "
"future versions may use it for access control."
),
)
cube_name: str | None = Field(
None,
description="Human-readable name. Defaults to ``cube_id`` if not provided.",
)


class CreateCubeResponse(BaseResponse[dict[str, Any]]):
"""Response model for creating a mem cube.

``data`` always contains ``cube_id`` and a ``created`` boolean. ``created``
is ``False`` when the cube already existed (idempotent path).
"""


class DeleteMemoryByRecordIdRequest(BaseRequest):
"""Request model for deleting memory by record id."""

Expand Down
133 changes: 133 additions & 0 deletions src/memos/api/routers/server_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
ChatBusinessRequest,
ChatPlaygroundRequest,
ChatRequest,
CreateCubeRequest,
CreateCubeResponse,
DeleteMemoryByRecordIdRequest,
DeleteMemoryByRecordIdResponse,
DeleteMemoryRequest,
Expand Down Expand Up @@ -95,6 +97,47 @@
status_tracker = TaskStatusTracker(redis_client=redis_client)
graph_db = components["graph_db"]

# Opt-in flag: when enabled, /product/add and /product/search return 404 if an
# explicit ``mem_cube_id`` / ``writable_cube_ids`` / ``readable_cube_ids`` refers
# to a cube that has not been registered via /product/create_cube. Default is
# off to preserve backward compatibility; see Issue #1681.
STRICT_CUBE_VALIDATION = os.getenv("MEMOS_STRICT_CUBE_VALIDATION", "false").lower() == "true"


def _cube_exists(cube_id: str) -> bool:
"""Return True if the cube has been registered (or had memories written)."""
if not cube_id:
return False
try:
return bool(graph_db.exist_user_name(user_name=cube_id).get(cube_id, False))
except Exception:
# If the registry is unavailable, fall back to allowing the request
# rather than blocking it. The underlying handler will surface the
# real error.
logger.warning("Cube existence check failed; skipping validation.", exc_info=True)
return True


def _validate_cube_or_404(cube_ids: list[str]) -> None:
"""Raise HTTP 404 with a clear message if any cube id is missing.

Only checks explicit cube ids — callers should skip implicit defaults
(e.g. when the request didn't set ``mem_cube_id`` and the handler is
falling back to ``user_id``).
"""
if not STRICT_CUBE_VALIDATION:
return
missing = [cid for cid in cube_ids if cid and not _cube_exists(cid)]
if missing:
raise HTTPException(
status_code=404,
detail=(
f"Cube {missing[0]!r} does not exist. Create it via "
"POST /product/create_cube or omit mem_cube_id to use the "
"default cube."
),
)


# =============================================================================
# Search API Endpoints
Expand All @@ -107,7 +150,21 @@ def search_memories(search_req: APISearchRequest):
Search memories for a specific user.

This endpoint uses the class-based SearchHandler for better code organization.

When ``MEMOS_STRICT_CUBE_VALIDATION`` is enabled, an explicit
``mem_cube_id`` / ``readable_cube_ids`` referring to a cube that has
not been registered will return HTTP 404 instead of an empty result.
"""
# Only validate explicit cube ids — when neither mem_cube_id nor
# readable_cube_ids is set, the handler implicitly falls back to user_id,
# which is the legacy default behaviour we must preserve.
explicit_cube_ids: list[str] = []
if search_req.mem_cube_id:
explicit_cube_ids.append(search_req.mem_cube_id)
if search_req.readable_cube_ids:
explicit_cube_ids.extend(search_req.readable_cube_ids)
_validate_cube_or_404(explicit_cube_ids)

search_results = search_handler.handle_search_memories(search_req)
return search_results

Expand All @@ -123,7 +180,19 @@ def add_memories(add_req: APIADDRequest):
Add memories for a specific user.

This endpoint uses the class-based AddHandler for better code organization.

When ``MEMOS_STRICT_CUBE_VALIDATION`` is enabled, an explicit
``mem_cube_id`` / ``writable_cube_ids`` referring to a cube that has
not been registered will return HTTP 404 instead of silently writing
to an orphan partition.
"""
explicit_cube_ids: list[str] = []
if add_req.mem_cube_id:
explicit_cube_ids.append(add_req.mem_cube_id)
if add_req.writable_cube_ids:
explicit_cube_ids.extend(add_req.writable_cube_ids)
_validate_cube_or_404(explicit_cube_ids)

return add_handler.handle_add_memories(add_req)


Expand Down Expand Up @@ -392,6 +461,70 @@ def exist_mem_cube_id(request: ExistMemCubeIdRequest):
)


@router.post(
"/create_cube",
summary="Create / register a mem cube",
response_model=CreateCubeResponse,
)
def create_cube(request: CreateCubeRequest):
"""Explicitly create / register a mem cube.

Server-mode HTTP API previously accepted arbitrary ``mem_cube_id`` values
on :http:post:`/product/add` but did not register the cube in the tree
registry, so subsequent :http:post:`/product/search` calls returned
empty results even though data was written to the vector store. See
Issue #1681 for context.

This endpoint is idempotent — calling it twice with the same
``cube_id`` returns ``created=False`` on the second call without
raising.
"""
cube_id = request.cube_id
owner_id = request.owner_id

if not cube_id:
raise HTTPException(status_code=400, detail="cube_id must be a non-empty string")

if hasattr(graph_db, "create_user_name"):
try:
created = bool(graph_db.create_user_name(user_name=cube_id, owner_id=owner_id))
except Exception as e:
logger.error("Failed to create cube %s: %s", cube_id, e, exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to create cube {cube_id!r}: {e}",
) from e
else:
# Backwards-compatibility shim for graph_db backends that have not
# adopted the create_user_name extension yet: treat the cube as
# registered if any memory exists for it; otherwise report
# not-implemented so integrators see a clear signal.
existing = bool(graph_db.exist_user_name(user_name=cube_id).get(cube_id, False))
if existing:
created = False
else:
raise HTTPException(
status_code=501,
detail=(
"The active graph_db backend does not support explicit cube "
"creation. Upgrade to a backend that implements "
"create_user_name, or add a memory via /product/add to "
"implicitly register the cube."
),
)

return CreateCubeResponse(
code=200,
message="Cube created" if created else "Cube already exists",
data={
"cube_id": cube_id,
"cube_name": request.cube_name or cube_id,
"owner_id": owner_id,
"created": created,
},
)


@router.post("/chat/stream/business_user", summary="Chat with MemOS for business user")
def chat_stream_business_user(chat_req: ChatBusinessRequest):
"""(inner) Chat with MemOS for a specific business user. Returns SSE stream."""
Expand Down
56 changes: 56 additions & 0 deletions src/memos/graph_dbs/neo4j.py
Original file line number Diff line number Diff line change
Expand Up @@ -2109,6 +2109,62 @@ def exist_user_name(self, user_name: str) -> dict[str, bool]:
)
raise

def create_user_name(self, user_name: str, owner_id: str | None = None) -> bool:
"""Register a cube (``user_name``) in the graph.

Creates an idempotent marker :Memory node so that subsequent calls
to :meth:`exist_user_name` return ``True`` and so that the tree
retriever has a registered partition for the cube. The marker has
a deterministic id (``_cube_marker:<user_name>``) to avoid
duplicates and is excluded from normal search by carrying a
``node_type`` of ``"cube_marker"``.

Args:
user_name: The cube id / user_name to register.
owner_id: Optional owner identifier stored as metadata on the
marker node.

Returns:
bool: ``True`` if a new marker was created, ``False`` if the
cube was already registered.
"""
if not user_name:
raise ValueError("user_name must be a non-empty string")

# Don't create a marker if any memory already exists for this cube.
if self.exist_user_name(user_name).get(user_name):
logger.info(f"[create_user_name] Cube {user_name} already exists; create is a no-op.")
return False

marker_id = f"_cube_marker:{user_name}"
query = """
MERGE (n:Memory {id: $id})
ON CREATE SET
n.memory = $memory,
n.user_name = $user_name,
n.owner_id = $owner_id,
n.node_type = 'cube_marker',
n.created_at = datetime(),
n.updated_at = datetime()
RETURN n.id AS id
"""
try:
with self.driver.session(database=self.db_name) as session:
session.run(
query,
id=marker_id,
memory="",
user_name=user_name,
owner_id=owner_id or "",
)
logger.info(f"[create_user_name] Registered cube marker for user_name {user_name}")
return True
except Exception as e:
logger.error(
f"[create_user_name] Failed to register cube {user_name}: {e}", exc_info=True
)
raise

def delete_node_by_mem_cube_id(
self,
mem_cube_id: str | None = None,
Expand Down
47 changes: 47 additions & 0 deletions src/memos/graph_dbs/polardb.py
Original file line number Diff line number Diff line change
Expand Up @@ -5044,6 +5044,53 @@ def escape_user_name(un: str) -> str:
)
raise

def create_user_name(self, user_name: str, owner_id: str | None = None) -> bool:
"""Register a cube (``user_name``) in the graph.

Creates an idempotent marker Memory node so that subsequent calls to
:meth:`exist_user_name` return ``True``. The marker has a
deterministic id (``_cube_marker:<user_name>``) and is tagged with
``node_type='cube_marker'`` so it is not surfaced in regular search
results.

Args:
user_name: The cube id / user_name to register.
owner_id: Optional owner identifier stored as metadata on the
marker node.

Returns:
bool: ``True`` if a new marker was created, ``False`` if the
cube was already registered.
"""
if not user_name:
raise ValueError("user_name must be a non-empty string")

if self.exist_user_name(user_name).get(user_name):
logger.info(f"[create_user_name] Cube {user_name} already exists; create is a no-op.")
return False

marker_id = f"_cube_marker:{user_name}"
try:
# Use add_node so that connection/auth/escape logic stays in
# one place. The marker node carries ``node_type='cube_marker'``
# so search/scroll routines can skip it.
self.add_node(
id=marker_id,
memory="",
metadata={
"node_type": "cube_marker",
"owner_id": owner_id or "",
},
user_name=user_name,
)
logger.info(f"[create_user_name] Registered cube marker for user_name {user_name}")
return True
except Exception as e:
logger.error(
f"[create_user_name] Failed to register cube {user_name}: {e}", exc_info=True
)
raise

@timed
def delete_node_by_mem_cube_id(
self,
Expand Down
Loading