diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a97f06..77297b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,9 @@ Hy Memory and MemHub are versioned and released independently. ## Unreleased +- `search(..., layers=["l1_raw"])` now explicitly searches SDK raw records in + pro/ultra mode and keeps existing user/agent/session/source filters; default + search continues to hide raw. - Added `import_memory_exact` for idempotent migration of an existing semantic memory with its canonical ID, lifecycle and supersession links, without extraction or reconciliation. diff --git a/hy_memory/client.py b/hy_memory/client.py index 94b4192..e2ca32d 100644 --- a/hy_memory/client.py +++ b/hy_memory/client.py @@ -3469,6 +3469,66 @@ class HyMemoryClient: "offset": offset, } + async def _search_explicit_raw_layer( + self, + query: str, + *, + user_ids: List[str], + agent_ids: List[str], + session_ids: List[str], + include_sources: List[str], + exclude_sources: List[str], + limit: int, + min_score: float, + created_after: Optional[float], + request_id: str, + t0: float, + query_embedding: Optional[List[float]], + ) -> Dict[str, Any]: + """Implement `search(..., layers=['l1_raw'])` without a second API.""" + from .models.memory import MemoryLayer, MemoryStatus + + embedding = query_embedding or await self._embed_service.embed(query) + fetch = min(max(limit * (10 if session_ids else 2), limit), 10_000) + hits = await self._vector_store.search( + query_embedding=embedding, + user_ids=user_ids, + agent_ids=agent_ids, + include_sources=include_sources, + exclude_sources=exclude_sources, + layers=[MemoryLayer.L1_RAW], + limit=fetch, + score_threshold=min_score, + status_filter=[MemoryStatus.ACTIVE], + only_latest=False, + created_after=created_after, + ) + sessions = set(session_ids) + rows: List[Dict[str, Any]] = [] + for hit in hits: + node = hit.get("node") + if node is None or node.layer != MemoryLayer.L1_RAW: + continue + if sessions and node.session_id not in sessions: + continue + row = self._raw_node_to_public(node) + rows.append( + { + "memory_id": row.pop("raw_id"), + **row, + "layer": MemoryLayer.L1_RAW.value, + "status": MemoryStatus.ACTIVE.value, + "score": round(float(hit.get("score", 0.0)), 4), + } + ) + if len(rows) >= limit: + break + return { + "request_id": request_id, + "memories": {"schema": [], "proactive": [], "normal": rows}, + "elapsed_ms": round((time.perf_counter() - t0) * 1000, 2), + } + @staticmethod def _public_search_views( result_memories: List[Dict[str, Any]], @@ -4129,6 +4189,22 @@ class HyMemoryClient: effective_search_layers = [MemoryLayer.L1_RAW] if lite_mode else layers effective_schema_limit = 0 if lite_mode else schema_limit effective_intention_limit = 0 if lite_mode else intention_limit + normalized_search_layers = self._normalize_layers(effective_search_layers) + if normalized_search_layers == [MemoryLayer.L1_RAW]: + return await self._search_explicit_raw_layer( + effective_target_query, + user_ids=effective_user_ids, + agent_ids=effective_agent_ids, + session_ids=effective_session_ids, + include_sources=effective_include_sources, + exclude_sources=effective_exclude_sources, + limit=limit, + min_score=min_score, + created_after=effective_created_after, + request_id=request_id, + t0=t0, + query_embedding=query_embedding, + ) read_req = ReadRequest( query=effective_target_query, @@ -4149,7 +4225,7 @@ class HyMemoryClient: layers=( [ layer.value - for layer in self._normalize_layers(effective_search_layers) + for layer in normalized_search_layers ] if effective_search_layers else None ), @@ -4174,7 +4250,7 @@ class HyMemoryClient: _wanted_layers = ( { layer.value - for layer in self._normalize_layers(effective_search_layers) + for layer in normalized_search_layers } if effective_search_layers else None ) diff --git a/tests/test_raw_public_api.py b/tests/test_raw_public_api.py index 822ef80..cf2e12b 100644 --- a/tests/test_raw_public_api.py +++ b/tests/test_raw_public_api.py @@ -65,6 +65,32 @@ class RawStore: and (not include_sources or node.source in include_sources) ] + async def search( + self, + *, + user_ids, + agent_ids, + include_sources, + exclude_sources, + layers, + limit, + **_kwargs, + ): + rows = [] + for node in self.nodes.values(): + if node.user_id not in user_ids: + continue + if agent_ids and node.agent_id not in agent_ids: + continue + if include_sources and node.source not in include_sources: + continue + if exclude_sources and node.source in exclude_sources: + continue + if node.layer not in layers: + continue + rows.append({"node": node, "score": 0.9}) + return rows[:limit] + def _client(): client = HyMemoryClient.__new__(HyMemoryClient) @@ -225,3 +251,43 @@ async def test_exact_memory_import_rejects_raw_layer(): layer="l1_raw", request_id="request-memory-1", ) + + +@pytest.mark.asyncio +async def test_existing_search_contract_can_explicitly_search_raw_with_source_filter(): + client = _client() + for raw_id, session_id, source in ( + ("raw-1", "s1", "cursor"), + ("raw-2", "s2", "claude"), + ): + await HyMemoryClient.async_import_raw.__wrapped__( + client, + f"content {raw_id}", + user_id="u1", + agent_id="a1", + session_id=session_id, + source=source, + raw_id=raw_id, + request_id=f"request-{raw_id}", + ) + + result = await client._search_explicit_raw_layer( + "content", + user_ids=["u1"], + agent_ids=["a1"], + session_ids=["s2"], + include_sources=["claude"], + exclude_sources=["cursor"], + limit=5, + min_score=0.0, + created_after=None, + request_id="search-1", + t0=0.0, + query_embedding=[0.1, 0.2], + ) + + assert [item["memory_id"] for item in result["memories"]["normal"]] == [ + "raw-2" + ] + assert result["memories"]["normal"][0]["layer"] == "l1_raw" + assert result["memories"]["normal"][0]["source"] == "claude"