From 857ade5c5ae8e5323ecdaba6c41e22c7faddc3a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=A0=83?= Date: Wed, 2 Sep 2026 18:16:13 +0800 Subject: [PATCH] fix(agent_hub): collect qoder user-level auto memory and map it across frameworks --- ms_agent/agent_hub/_merge.py | 3 ++- ms_agent/agent_hub/frameworks/qoder.py | 14 +++++++++- tests/agent_hub/test_agent_frameworks.py | 4 ++- tests/agent_hub/test_merge.py | 34 ++++++++++++++++++++++++ tests/agent_hub/test_workspace.py | 22 +++++++++++++++ 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/ms_agent/agent_hub/_merge.py b/ms_agent/agent_hub/_merge.py index 538c87d9a..659385f27 100644 --- a/ms_agent/agent_hub/_merge.py +++ b/ms_agent/agent_hub/_merge.py @@ -537,7 +537,8 @@ def _is_private_file(product: str, path: str) -> bool: 'openclaw': 'MEMORY.md', 'qwenpaw': 'MEMORY.md', 'hermes': 'memories/MEMORY.md', - 'openhuman': 'MEMORY.md' + 'openhuman': 'MEMORY.md', + 'qoder': 'memory/MEMORY.md' }, { 'openclaw': 'IDENTITY.md', diff --git a/ms_agent/agent_hub/frameworks/qoder.py b/ms_agent/agent_hub/frameworks/qoder.py index f51939db5..b45b558a5 100644 --- a/ms_agent/agent_hub/frameworks/qoder.py +++ b/ms_agent/agent_hub/frameworks/qoder.py @@ -13,7 +13,9 @@ class QoderWorkspace(WorkspaceSpec): Qoder keeps user-level config at ``~/.qoder`` (project-level config lives in a project's ``.qoder/`` directory; point ``--local_dir`` at it to upload that instead). A sub-agent is one Markdown file ``agents/.md``; skills, - commands, rules and ``AGENTS.md`` are shared across sub-agents. + commands, rules, ``AGENTS.md`` and the user-level auto memory (``memory/``) + are shared across sub-agents -- Qoder memory is scoped per user / per + project, never per agent. """ @property @@ -30,11 +32,21 @@ def default_root(self) -> Path: @property def patterns(self) -> list[str]: + # ``memory/`` is the Qoder CLI user-level auto-memory root + # (``MEMORY.md`` index + topic ``.md`` files, per the official CLI + # docs). The project-level auto memory under + # ``projects//memory/`` is deliberately NOT + # collected: the directory name is a machine-specific encoding of the + # workspace path, and several projects each carry their own + # ``MEMORY.md`` -- flattening them into the single cross-framework + # memory slot would overwrite one project's memory with another's. return [ 'AGENTS.md', 'agents/{name}.md', 'commands/*.md', 'rules/*.md', + 'memory/MEMORY.md', + 'memory/*.md', 'skills/*/SKILL.md', 'skills/*/scripts/*', 'skills/*/references/*', diff --git a/tests/agent_hub/test_agent_frameworks.py b/tests/agent_hub/test_agent_frameworks.py index b90cd8044..60ec3a637 100644 --- a/tests/agent_hub/test_agent_frameworks.py +++ b/tests/agent_hub/test_agent_frameworks.py @@ -129,6 +129,8 @@ def _to_bytes(files: dict) -> dict: "agents/code-reviewer.md": "# Code Reviewer\nReview code for bugs and style.\n", "commands/review.md": "# /review\nTrigger a code review on the current file.\n", "rules/style-guide.md": "# Style Guide\nUse 4-space indentation for Python.\n", + "memory/MEMORY.md": "# Memory Index\n\n- [User language](user-language.md) — respond in Chinese\n", + "memory/user-language.md": "---\nname: user-language\nmetadata:\n type: user\n---\n\nUser speaks Chinese.\n", "skills/lint/SKILL.md": "# Lint\nRun linters on the codebase.\n", "skills/lint/scripts/run_lint.sh": "# lint runner\nrun flake8 on all project files\n", } @@ -452,7 +454,7 @@ def test_23_framework_structure(self): "qwenpaw": ["PROFILE.md", "BOOTSTRAP.md", "memory/story-notes.md"], "hermes": ["memories/USER.md"], "openhuman": ["SOUL.md", "IDENTITY.md", "HEARTBEAT.md", "wiki/interests.md"], - "qoder": ["agents/code-reviewer.md", "commands/review.md", "rules/style-guide.md"], + "qoder": ["agents/code-reviewer.md", "commands/review.md", "rules/style-guide.md", "memory/MEMORY.md"], } for fw, markers in framework_markers.items(): diff --git a/tests/agent_hub/test_merge.py b/tests/agent_hub/test_merge.py index 077565e71..ddbfbb0b3 100644 --- a/tests/agent_hub/test_merge.py +++ b/tests/agent_hub/test_merge.py @@ -265,6 +265,20 @@ def test_cross_product_user_md(self): def test_cross_product_memory_md(self): self.assertEqual(_resolve_target_path("nanobot", "memory/MEMORY.md", "openclaw"), "MEMORY.md") + # qoder user-level auto memory joins the same MEMORY.md group. + self.assertEqual( + _resolve_target_path("qoder", "memory/MEMORY.md", "hermes"), + "memories/MEMORY.md") + self.assertEqual( + _resolve_target_path("qoder", "memory/MEMORY.md", "openclaw"), + "MEMORY.md") + self.assertEqual( + _resolve_target_path("hermes", "memories/MEMORY.md", "qoder"), + "memory/MEMORY.md") + # ms-agent has no memory slot, so qoder memory has no semantic target + # there either (folds into the catch-all instead). + self.assertIsNone( + _resolve_target_path("qoder", "memory/MEMORY.md", "ms-agent")) def test_cross_product_ms_agent_profile(self): # ms-agent PROFILE.md -> qwenpaw maps to memory/USER.md (USER group). @@ -303,6 +317,26 @@ def test_same_product_imports_directly(self): self.assertIn("SOUL.md", result.merged_files) self.assertEqual(result.merged_files["SOUL.md"], "my soul") + def test_qoder_memory_maps_and_topic_files_pass_through(self): + """Cross-framework, qoder's user-level memory index maps onto the + target's MEMORY.md slot while topic files keep their original path + (kept only if the target spec accepts it); both travel verbatim -- + memory is user data, never rebased onto a target template.""" + result = merge_resources( + incoming={ + "memory/MEMORY.md": "# Memory Index\n\n- [t](t.md) — x\n", + "memory/t.md": "---\nname: t\n---\n\ntopic body\n", + }, + source_product="qoder", + target_product="openclaw", + source_defaults={}, + target_defaults={}, + ) + self.assertEqual(result.merged_files["MEMORY.md"], + "# Memory Index\n\n- [t](t.md) — x\n") + self.assertEqual(result.merged_files["memory/t.md"], + "---\nname: t\n---\n\ntopic body\n") + def test_fills_missing_from_target_defaults(self): """merge_resources fills target defaults for absent source files.""" result = merge_resources( diff --git a/tests/agent_hub/test_workspace.py b/tests/agent_hub/test_workspace.py index 6f1d8b27f..a27cf6401 100644 --- a/tests/agent_hub/test_workspace.py +++ b/tests/agent_hub/test_workspace.py @@ -37,6 +37,28 @@ def test_qoder_collects_named_agent_plus_shared(self): self.assertIn("skills/x/SKILL.md", collected) self.assertNotIn("agents/other.md", collected) + def test_qoder_collects_user_level_memory_not_project_level(self): + """Qoder CLI auto memory is user-level (``memory/``) plus + project-level (``projects//memory/``). Only the + user-level root is portable: the project directory name is a + machine-specific encoding of the workspace path and several projects + each carry a ``MEMORY.md`` that would overwrite one another in the + single cross-framework memory slot.""" + (self.root / "memory").mkdir() + (self.root / "memory" / "MEMORY.md").write_text("# Memory Index\n") + (self.root / "memory" / "user-language.md").write_text("topic\n") + proj_mem = self.root / "projects" / "-Users-test-demo" / "memory" + proj_mem.mkdir(parents=True) + (proj_mem / "MEMORY.md").write_text("# Project Memory Index\n") + + spec = QoderWorkspace(agent_name="default", local_dir=self.root) + collected = spec.collect() + + self.assertIn("memory/MEMORY.md", collected) + self.assertIn("memory/user-language.md", collected) + self.assertNotIn( + "projects/-Users-test-demo/memory/MEMORY.md", collected) + def test_hermes_excludes_framework_skills_keeps_user_skills(self): """hermes collect drops bundled/framework skills (identified by a builtin_skill_version / metadata.copaw frontmatter marker or a