From c13d5b141b78712b624973ad677cf6897e08058d Mon Sep 17 00:00:00 2001 From: MemOS Autodev Date: Tue, 25 Aug 2026 17:39:06 +0800 Subject: [PATCH] fix: align get/list read scope with add write scope `memos add` stores memories keyed by conversation_id, but `memos get` listed only by user_id, so freshly added memories were invisible to the list view. Forward conversation_id through the same fallback chain as cmd_add (CLI arg -> config.defaults.conversation_id -> DEFAULT_CONVERSATION_ID) into the POST /get/memory body. Also register the documented `memos list` command as an alias of `get`, add --conversation-id to both entrypoints, and teach the display layer to parse the text_mem bucket envelope so scoped results render instead of showing 0 records. Update README (en/zh) and memos-memory skill docs. Co-Authored-By: Claude Opus 4.7 (1M context) --- README-zh.md | 13 ++ README.md | 13 ++ skills/memos-memory/SKILL.md | 3 +- skills/memos-memory/references/memos-get.md | 7 +- src/memos_cli/backend/memory_api.py | 2 + src/memos_cli/commands/memory.py | 26 ++++ src/memos_cli/commands/memory_cmd.py | 17 ++- src/memos_cli/main.py | 4 +- src/memos_cli/output.py | 7 +- tests/test_memory_api_paths.py | 14 ++ tests/test_memory_get_scope.py | 146 ++++++++++++++++++++ 11 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 tests/test_memory_get_scope.py diff --git a/README-zh.md b/README-zh.md index f7a2a99..b9aaca7 100644 --- a/README-zh.md +++ b/README-zh.md @@ -210,6 +210,7 @@ memos get user_123 --format json --detail detail - `[USER_ID]`:用户维度;实际上必填,但若不传 CLI 会回退到配置中的 `defaults.user_id`。 - `--user-id`:`[USER_ID]` 的兼容别名;可选;与 `[USER_ID]` 使用同样的回退规则。 +- `--conversation-id`:会话维度;可选;默认取配置中的 `defaults.conversation_id`。传入与 `memos add` 相同的值可保证读路径与写路径作用域一致。 - `--page`:页码;可选;不传时接口默认值为 `1`。 - `--size`:指定每一类记忆在当前页返回的条目数量;可选;不传时接口默认值为 `10`。 - `--include-preference`:是否召回偏好记忆;可选;接受 `true` 或 `false`;不传时默认 `true`。 @@ -217,6 +218,18 @@ memos get user_123 --format json --detail detail - `--format`:输出格式;可选;默认值为 `agent`。 - `--detail`:非 JSON 输出的详略级别;可选;默认值为 `simple`;支持 `simple`、`detail`。 +### `memos list` + +`memos get` 的别名,用于列出当前用户与会话作用域下的记忆。 + +示例: + +```bash +memos list user_123 --conversation-id conv_001 --format table --detail simple +``` + +参数与 [`memos get`](#memos-get) 完全一致。 + ### `memos delete` 用于删除一条记忆,或删除某个用户的全部记忆。 diff --git a/README.md b/README.md index 3b5cc5d..cd5d83b 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ Parameters: - `[USER_ID]`: Retrieval scope; effectively required, but if omitted the CLI falls back to configured `defaults.user_id`. - `--user-id`: Alias of `[USER_ID]`; optional; same fallback as `[USER_ID]`. +- `--conversation-id`: Conversation scope for retrieval; optional; defaults to configured `defaults.conversation_id`. Passing the same value used by `memos add` keeps reads consistent with writes. - `--page`: Page number; optional; API default is `1` when omitted. - `--size`: Number of items returned per memory category on the current page; optional; API default is `10` when omitted. - `--include-preference`: Whether to include preference memory; optional; accepts `true` or `false`; defaults to `true` when omitted. @@ -222,6 +223,18 @@ Parameters: - `--format`: Output format; optional; defaults to `agent`. - `--detail`: Output detail level for non-JSON formats; optional; defaults to `simple`; supported values: `simple`, `detail`. +### `memos list` + +Alias of `memos get`; lists memories scoped to the current user and conversation. + +Example: + +```bash +memos list user_123 --conversation-id conv_001 --format table --detail simple +``` + +Parameters are identical to [`memos get`](#memos-get). + ### `memos delete` Delete one memory, or delete all memories for a user, using the documented delete API. diff --git a/skills/memos-memory/SKILL.md b/skills/memos-memory/SKILL.md index f464645..c609fb4 100644 --- a/skills/memos-memory/SKILL.md +++ b/skills/memos-memory/SKILL.md @@ -57,7 +57,8 @@ Command examples: - `memos extract "" --user-id --format json` - `memos search "" --user-id --format agent --detail simple` - `memos chat "" --user-id --format agent` -- `memos get --format json --detail detail` +- `memos get --conversation-id --format json --detail detail` +- `memos list --conversation-id --format table --detail simple` - `memos origin --format json` - `memos delete --format json` - `memos delete --user-id --format json` diff --git a/skills/memos-memory/references/memos-get.md b/skills/memos-memory/references/memos-get.md index f4e60ed..c4041e3 100644 --- a/skills/memos-memory/references/memos-get.md +++ b/skills/memos-memory/references/memos-get.md @@ -24,6 +24,7 @@ Common flags: - `[USER_ID]` - `--user-id` +- `--conversation-id` - `--page` - `--size` - `--filter` @@ -35,9 +36,11 @@ Common flags: Example: ```bash -memos get user_123 --format json --detail detail +memos get user_123 --conversation-id conv_001 --format json --detail detail ``` Working rules: -- `get` returns scoped records for the requested `user_id`; +- `get` returns scoped records for the requested `user_id` and `conversation_id`; +- when `--conversation-id` is omitted, the CLI uses the configured `defaults.conversation_id`; pass the same conversation used by `memos add` so reads see the records just written; +- `memos list` is an alias of `memos get` and accepts the same flags; - do not prepend `memos --help` when `get` is the already known goal. diff --git a/src/memos_cli/backend/memory_api.py b/src/memos_cli/backend/memory_api.py index 29fe588..631873b 100644 --- a/src/memos_cli/backend/memory_api.py +++ b/src/memos_cli/backend/memory_api.py @@ -172,6 +172,8 @@ def get_memories(self, **kwargs: Any) -> dict[str, Any]: raise APIError("Get memory requires user_id") payload: dict[str, Any] = {"user_id": user_id} + if kwargs.get("conversation_id"): + payload["conversation_id"] = kwargs["conversation_id"] if kwargs.get("page") is not None: payload["page"] = kwargs["page"] if kwargs.get("size") is not None: diff --git a/src/memos_cli/commands/memory.py b/src/memos_cli/commands/memory.py index 0c1d0a3..2c31e96 100644 --- a/src/memos_cli/commands/memory.py +++ b/src/memos_cli/commands/memory.py @@ -185,6 +185,7 @@ def chat( def get( user_id_arg: str | None = typer.Argument(None, help="User ID"), user_id: str | None = typer.Option(None, "--user-id", help="User ID"), + conversation_id: str | None = typer.Option(None, "--conversation-id", help="Conversation ID"), page: int | None = typer.Option(None, "--page", min=1, help="Page number"), size: int | None = typer.Option(None, "--size", min=1, help="Page size"), include_preference: str | None = typer.Option(None, "--include-preference", help="Include preference memory: true or false"), @@ -195,6 +196,31 @@ def get( """Get memories via the documented get_memory API.""" cmd_get( user_id=user_id_arg or user_id, + conversation_id=conversation_id, + page=page, + size=size, + include_preference=include_preference, + include_tool_memory=include_tool_memory, + output_format=output_format, + detail=detail, + ) + + +def list( + user_id_arg: str | None = typer.Argument(None, help="User ID"), + user_id: str | None = typer.Option(None, "--user-id", help="User ID"), + conversation_id: str | None = typer.Option(None, "--conversation-id", help="Conversation ID"), + page: int | None = typer.Option(None, "--page", min=1, help="Page number"), + size: int | None = typer.Option(None, "--size", min=1, help="Page size"), + include_preference: str | None = typer.Option(None, "--include-preference", help="Include preference memory: true or false"), + include_tool_memory: str | None = typer.Option(None, "--include-tool-memory", help="Include tool memory: true or false"), + output_format: str | None = typer.Option(None, "--format", help=FORMAT_HELP), + detail: str | None = typer.Option(None, "--detail", help=DETAIL_HELP), +): + """List memories scoped to the current user/conversation (alias of `get`).""" + cmd_get( + user_id=user_id_arg or user_id, + conversation_id=conversation_id, page=page, size=size, include_preference=include_preference, diff --git a/src/memos_cli/commands/memory_cmd.py b/src/memos_cli/commands/memory_cmd.py index d63cdbd..97cdca5 100644 --- a/src/memos_cli/commands/memory_cmd.py +++ b/src/memos_cli/commands/memory_cmd.py @@ -612,6 +612,7 @@ def cmd_chat( def cmd_get( *, user_id: str | None, + conversation_id: str | None, page: int | None, size: int | None, include_preference: str | None, @@ -619,15 +620,27 @@ def cmd_get( output_format: str, detail: str, ) -> None: - """Execute get.""" + """Execute get. + + The read path must use the same conversation scope as the write path + (``cmd_add``): the server stores memories keyed by ``conversation_id``, + so listing without one silently misses records added to the default + conversation. + """ start_time = time.time() final_output = resolve_output_format(output_format) final_detail = validate_detail(detail) try: config, backend = _load_backend() final_user_id = user_id or config.defaults.user_id + final_conversation_id = ( + conversation_id + or config.defaults.conversation_id + or DEFAULT_CONVERSATION_ID + ) response = backend.get_memories( user_id=final_user_id, + conversation_id=final_conversation_id, page=page, size=size, include_preference=parse_bool_option(include_preference, option_name="--include-preference"), @@ -645,7 +658,7 @@ def cmd_get( data=memories, duration_ms=duration_ms, count=len(memories), - scope={"user_id": final_user_id}, + scope={"user_id": final_user_id, "conversation_id": final_conversation_id}, detail=final_detail, ) return diff --git a/src/memos_cli/main.py b/src/memos_cli/main.py index cc44ca1..f9217b8 100644 --- a/src/memos_cli/main.py +++ b/src/memos_cli/main.py @@ -14,7 +14,7 @@ from memos_cli.completion import register_completion_compat from memos_cli.commands.init import init_cmd, uninstall_cmd from memos_cli.commands.config_cmd import config_app -from memos_cli.commands.memory import add, extract, feedback, rerank, search, chat, get, delete, origin +from memos_cli.commands.memory import add, extract, feedback, rerank, search, chat, get, list, delete, origin from memos_cli.commands.message import message, status from memos_cli.commands.kb import kb_app from memos_cli.state import set_runtime_options @@ -34,6 +34,7 @@ class CommandFirstTyperGroup(TyperGroup): "add", "search", "get", + "list", "origin", "delete", "extract", @@ -136,6 +137,7 @@ def _fire_telemetry(command_name: str, extra: dict | None = None): app.command(rich_help_panel="Memory Operations")(feedback) app.command(rich_help_panel="Memory Operations")(search) app.command(rich_help_panel="Memory Operations")(get) +app.command(rich_help_panel="Memory Operations")(list) app.command(rich_help_panel="Memory Operations")(origin) app.command(rich_help_panel="Memory Operations")(delete) diff --git a/src/memos_cli/output.py b/src/memos_cli/output.py index 2db9a0f..d91b86f 100644 --- a/src/memos_cli/output.py +++ b/src/memos_cli/output.py @@ -8,7 +8,7 @@ from rich.table import Table from rich.text import Text -from memos_cli.backend.normalizers import build_skill_memory_text +from memos_cli.backend.normalizers import build_skill_memory_text, extract_memory_list from memos_cli.branding import ACCENT_COLOR, BRAND_COLOR, DIM_COLOR @@ -62,6 +62,11 @@ def extract_memory_records_from_response(data: dict[str, Any], *, detail: str = items.extend(("memory", item) for item in raw_data["memories"] if isinstance(item, dict)) if not items and any(key in raw_data for key in ("id", "memory", "text", "memory_value", "content")): items.append(("memory", raw_data)) + # The official get_memory API may wrap records in a `text_mem` bucket + # envelope; parse it the same way `extract_memory_list` does so list/get + # do not silently render zero records. + if not items and isinstance(raw_data.get("text_mem"), list): + items.extend(("memory", item) for item in extract_memory_list(raw_data) if isinstance(item, dict)) elif isinstance(data, dict) and isinstance(data.get("results"), list): items = [("memory", item) for item in data["results"] if isinstance(item, dict)] diff --git a/tests/test_memory_api_paths.py b/tests/test_memory_api_paths.py index ceb0f50..c4f2e8d 100644 --- a/tests/test_memory_api_paths.py +++ b/tests/test_memory_api_paths.py @@ -36,6 +36,20 @@ def test_search_memories_uses_documented_endpoint_only(self) -> None: self.assertEqual(transport.calls[0][1], "/search/memory") self.assertEqual(transport.calls[0][2]["json_body"]["knowledgebase_ids"], ["base123"]) + def test_get_memories_forwards_conversation_id(self) -> None: + transport = RecordingTransport() + api = MemoryAPI(transport) + + api.get_memories(user_id="user_1", conversation_id="conv_1", page=2, size=25) + + self.assertEqual(len(transport.calls), 1) + self.assertEqual(transport.calls[0][1], "/get/memory") + body = transport.calls[0][2]["json_body"] + self.assertEqual(body["user_id"], "user_1") + self.assertEqual(body["conversation_id"], "conv_1") + self.assertEqual(body["page"], 2) + self.assertEqual(body["size"], 25) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_memory_get_scope.py b/tests/test_memory_get_scope.py new file mode 100644 index 0000000..e3fcc36 --- /dev/null +++ b/tests/test_memory_get_scope.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from memos_cli.commands import memory, memory_cmd +from memos_cli.config import MemOSConfig, PlatformConfig +from memos_cli.output import extract_memory_records_from_response + + +class MemoryGetScopeTests(unittest.TestCase): + """The get/list read path must use the same conversation scope as add.""" + + def test_cmd_get_forwards_conversation_id_to_backend(self) -> None: + config = MemOSConfig( + platform=PlatformConfig( + api_key="test-key", + base_url="https://example.test/api", + ) + ) + config.defaults.user_id = "user_1" + config.defaults.conversation_id = "conversation_1" + + captured: dict = {} + + class Backend: + def get_memories(self, **kwargs): + captured.update(kwargs) + return {"data": {"text_mem": []}} + + with patch.object(memory_cmd, "_load_backend", return_value=(config, Backend())): + with patch.object(memory_cmd, "format_json"): + memory_cmd.cmd_get( + user_id=None, + conversation_id=None, + page=1, + size=50, + include_preference=None, + include_tool_memory=None, + output_format="json", + detail="simple", + ) + + self.assertEqual(captured["user_id"], "user_1") + self.assertEqual(captured["conversation_id"], "conversation_1") + + def test_cmd_get_prefers_explicit_conversation_id(self) -> None: + config = MemOSConfig( + platform=PlatformConfig( + api_key="test-key", + base_url="https://example.test/api", + ) + ) + config.defaults.user_id = "user_1" + config.defaults.conversation_id = "conversation_1" + + captured: dict = {} + + class Backend: + def get_memories(self, **kwargs): + captured.update(kwargs) + return {"data": {"text_mem": []}} + + with patch.object(memory_cmd, "_load_backend", return_value=(config, Backend())): + with patch.object(memory_cmd, "format_json"): + memory_cmd.cmd_get( + user_id="user_2", + conversation_id="conv_explicit", + page=1, + size=50, + include_preference=None, + include_tool_memory=None, + output_format="json", + detail="simple", + ) + + self.assertEqual(captured["user_id"], "user_2") + self.assertEqual(captured["conversation_id"], "conv_explicit") + + def test_get_entrypoint_passes_conversation_id(self) -> None: + with patch.object(memory, "cmd_get") as cmd_get: + memory.get( + user_id_arg=None, + user_id="user_1", + conversation_id="conv_1", + page=2, + size=25, + include_preference="true", + include_tool_memory="false", + output_format="json", + detail="detail", + ) + + self.assertEqual(cmd_get.call_count, 1) + self.assertEqual(cmd_get.call_args.kwargs["conversation_id"], "conv_1") + self.assertEqual(cmd_get.call_args.kwargs["user_id"], "user_1") + + def test_list_entrypoint_delegates_to_cmd_get(self) -> None: + with patch.object(memory, "cmd_get") as cmd_get: + memory.list( + user_id_arg="user_1", + user_id=None, + conversation_id="conv_1", + page=1, + size=10, + include_preference=None, + include_tool_memory=None, + output_format="table", + detail="simple", + ) + + self.assertEqual(cmd_get.call_count, 1) + self.assertEqual(cmd_get.call_args.kwargs["conversation_id"], "conv_1") + self.assertEqual(cmd_get.call_args.kwargs["user_id"], "user_1") + + +class GetMemoryTextMemParsingTests(unittest.TestCase): + """list/get must not silently drop records served via the text_mem envelope.""" + + def test_extract_records_reads_text_mem_bucket(self) -> None: + response = { + "code": 0, + "data": { + "text_mem": [ + { + "session_id": "s1", + "conversation_id": "conv_1", + "memories": [ + {"id": "mem_1", "memory": "User likes coffee"}, + {"id": "mem_2", "text": "Deployment is at 3pm"}, + ], + } + ] + }, + } + + records = extract_memory_records_from_response(response, detail="simple") + + self.assertEqual(len(records), 2) + contents = {record.get("memory") for record in records} + self.assertIn("User likes coffee", contents) + self.assertIn("Deployment is at 3pm", contents) + + +if __name__ == "__main__": + unittest.main()