diff --git a/src/mex/cli/integrate.py b/src/mex/cli/integrate.py index 57c2de6..8cc6da2 100644 --- a/src/mex/cli/integrate.py +++ b/src/mex/cli/integrate.py @@ -1,7 +1,7 @@ """``mex integrate`` 命令:生成 agent 的 skill 说明书与 README(M8)。 用法: - mex integrate claude|opencode|dsh [--scope project|global] [--json] + mex integrate claude|opencode|workbuddy|dsh [--scope project|global] [--json] """ from __future__ import annotations @@ -14,14 +14,14 @@ from mex.cli.common import UserError, print_json from mex.services.integrate import AGENT_TARGETS, AgentName, Scope, IntegrationResult, integrate -_SUPPORTED_AGENTS = "claude, opencode, dsh" +_SUPPORTED_AGENTS = "claude, opencode, workbuddy, dsh" _SCOPES = ("project", "global") @app.command("integrate", help="Generate agent integration files (skill guide + README)") @run def integrate_cmd( - agent: str = typer.Argument(..., help="Target agent: claude / opencode / dsh"), + agent: str = typer.Argument(..., help="Target agent: claude / opencode / workbuddy / dsh"), scope: str = typer.Option("global", "--scope", help="project (current dir) / global (user dir, default)"), json_output: bool = typer.Option(False, "--json", help="Output as JSON"), ) -> None: @@ -59,6 +59,12 @@ def _next_steps(agent: AgentName) -> str: f"Next steps: the skill is ready at {skill}. " "Start a new session for it to take effect, then ask the agent to run `mex profile` to verify." ) + if agent == "workbuddy": + skill = "~/.workbuddy/skills/mex/SKILL.md (global), ./.workbuddy/skills/mex/SKILL.md (project)" + return ( + f"Next steps: the skill is ready at {skill}. " + "Start a new WorkBuddy session for it to take effect, then ask the agent to run `mex profile` to verify." + ) # dsh return ( "Next steps: this is a DSH bundle directory. To install:\n" diff --git a/src/mex/integration/workbuddy/README.md b/src/mex/integration/workbuddy/README.md new file mode 100644 index 0000000..dc0b6a2 --- /dev/null +++ b/src/mex/integration/workbuddy/README.md @@ -0,0 +1,46 @@ +# meX × WorkBuddy:对接步骤 + +> 由 `mex integrate workbuddy` 生成,生成目录:`{{AGENT_DIR}}` + +本目录包含 meX 与 WorkBuddy 对接所需的文件: + +| 文件 | 用途 | +|---|---| +| `mex/SKILL.md` | skill 说明书(教 WorkBuddy 何时读取 / 写入记忆) | +| `README.md` | 本文件(对接步骤) | + +> 说明:本集成以 skill 承载读写引导(与 OpenCode / Claude Code 集成一致), +> 不依赖会话结束 hook(WorkBuddy / OpenCode 均无官方 SessionEnd hook), +> hook 能力留待后续扩展。 + +## 前置条件 + +1. meX 已初始化:`mex init`(数据目录 `{{MEX_HOME}}`)。 +2. `mex` 命令在 PATH 中(项目 `pip install -e .` 或 `uv sync` 安装后生效)。 + +## 对接步骤 + +### 1. 安装 skill(教 WorkBuddy 用记忆) + +skill 已生成到本目录 `mex/SKILL.md`,WorkBuddy 会从以下位置自动发现 skill: + +- 全局(推荐,所有项目生效):`{{AGENT_DIR}}/mex/SKILL.md`(即 ~/.workbuddy/skills/mex/SKILL.md) +- 项目级(仅当前项目):`./.workbuddy/skills/mex/SKILL.md` + +如果生成目录与上述位置一致,直接新开一个 WorkBuddy 会话即可生效; +若不一致,把 `mex/` 目录复制到对应位置。 + +> 注意:项目级生成到 `./.workbuddy/skills/`,该目录通常被项目 `.gitignore` +> 忽略(属于用户本地配置,不含项目源码)。 + +### 2. 验证 + +1. 新开一个 WorkBuddy 会话(让新 skill 生效)。 +2. 让 WorkBuddy 执行 `mex profile`,确认能输出用户画像。 +3. 对 WorkBuddy 说"记住这个:我喜欢喝美式咖啡",确认它执行 `mex extract "我喜欢喝美式咖啡"`。 +4. 运行 `mex list` 能看到新记忆。 + +## 数据与回滚 + +- 数据目录:`{{MEX_HOME}}`(数据库 `{{DB_PATH}}`,可用 `mex export` 备份)。 +- 想取消集成:删掉 skill 目录(`mex/`)即可,不影响既有记忆。 diff --git a/src/mex/integration/workbuddy/skill.md b/src/mex/integration/workbuddy/skill.md new file mode 100644 index 0000000..a5aafd1 --- /dev/null +++ b/src/mex/integration/workbuddy/skill.md @@ -0,0 +1,80 @@ +--- +name: mex +description: >- + meX 个人记忆系统的使用指南。当对话涉及用户的个人背景(工作、生活、健康、情绪、偏好、家庭)、 + 用户说"记住这个"或类似表述、或需要回忆用户之前说过的事情时使用。通过 mex CLI 读写本地记忆库。 +--- + +# meX 个人记忆助手(WorkBuddy skill) + +> 由 `mex integrate workbuddy` 生成。数据目录:`{{MEX_HOME}}` + +你是 meX 个人记忆系统在 WorkBuddy 中的使用指南。meX 是本地优先的个人记忆库,帮你记住用户的 +画像(schema.yaml 声明的画像槽位,形如 `topic.sub_topic`)与画像外记录(临时/近期状态, +按时间索引)。按以下规则在对话中使用。 + +## 1. 对话开始时:加载用户背景 + +执行 `mex profile`,把输出的用户画像作为对话背景注入上下文。 +如果命令失败(如未初始化),提示用户先运行 `mex init`。 + +## 2. 涉及生活、情绪或近期状态时:召回近期记录 + +当对话涉及用户的生活、情绪或"最近怎么样"之类的内容时,先执行: + +``` +mex search --since <7 天前的日期,格式 YYYY-MM-DD> +``` + +拉取最近 7 天的记忆(含画像外记录),感知用户最近发生了什么。 + +## 3. 需要细节时:定向检索 + +需要某领域细节(如用户的偏好、工作信息)时,执行: + +``` +mex search --topic <领域> [--keyword ...] +``` + +`--topic` 用画像领域(如 work/health/finance),`--keyword` 做内容关键词过滤。 +不确定有哪些领域时,先 `mex profile` 或 `mex list` 查看。 + +## 4. 用户明确说"记住这个"时:即时写入 + +用户明确说"记住这个"(或类似表述)时,把用户原话交给 meX 抽取: + +``` +mex extract "<用户原话>" +``` + +`mex extract` 内部调用 LLM 抽取,会自动把信息归类到画像槽位或画像外记录,无需你手动分类。 + +简单、明确的单条事实也可以直接用 `mex add`: + +``` +mex add --topic <领域> --sub-topic <字段> --content "<事实>" +``` + +(省略 `--sub-topic` 时作为画像外记录写入。) + +## 5. 写作原则(写记忆前必须遵守) + +meX 是跨项目记忆系统,写下的记忆会在任何项目、任何时间被长期读取,必须脱离当前会话上下文自包含: + +- **具体**:保留名称、数字、日期、角色、原因;不写模糊概括。 +- **独立**:不读本次对话上下文也能理解;禁止"本项目/它/那个/这家/那边"等指代,写具体项目名、公司名、人名。 +- **有用**:只记影响未来对话、决策或行动的信息,不记过程流水。 +- **不重复**:与已有记忆相同的内容不再写入(不确定时先 `mex search` 查一下)。 +- **时间**:用具体日期(YYYY-MM-DD),禁止"最近/昨天/下周/前几天"等相对表述。 +- **画像 vs 画像外**:稳定事实(姓名、公司、职位、学历、家庭、偏好)写入画像槽位(topic + sub_topic);临时状态(今天完成的事、情绪、计划、近况)作为画像外记录(省略 sub_topic)。 +- **全称**:人名/项目名用全称,首次出现时必要时附简短说明(如"meX(本地记忆系统)")。 + +## 6. 不要主动频繁调用写命令 + +除非用户明确要求或说出"记住这个",否则不要主动调用 `mex extract` / `mex add`—— +写路径按需触发,频繁调用会重复花费 LLM 成本或写入噪音。 + +## 其他 + +- 任何命令报错时,参考 `mex --help` 查看用法。 +- 数据目录:`{{MEX_HOME}}`(数据库 `{{DB_PATH}}`,环境变量 `MEX_HOME` 可覆盖)。 diff --git a/src/mex/services/integrate.py b/src/mex/services/integrate.py index 6b1a3d8..dbe2713 100644 --- a/src/mex/services/integrate.py +++ b/src/mex/services/integrate.py @@ -2,9 +2,9 @@ 仅生成文件、不执行任何外部命令;幂等(重复执行覆盖旧文件,不报错)。 -claude / opencode 产出 skill 说明书(写入官方 skills/mex/SKILL.md 发现路径)+ README.md -(写入 agent 配置目录)。不再生成 hooks.md——OpenCode 原生不支持 SessionEnd hook -(官方配置 schema 无 hooks 字段),写路径由 skill 引导 agent 即时写承担,hook 能力留待扩展; +claude / opencode / workbuddy 产出 skill 说明书 + README.md(写入 agent 的 skill 发现目录)。 +不再生成 hooks.md——OpenCode 原生不支持 SessionEnd hook(官方配置 schema 无 hooks 字段), +写路径由 skill 引导 agent 即时写承担,hook 能力留待扩展; dsh 产出标准 DSH bundle 目录(index.js + panel.js + client.js + package.json + cordis.patch.yml + README.md)。 """ @@ -21,16 +21,19 @@ from mex.cli.common import UserError from mex.config import get_mex_home -AgentName = Literal["claude", "opencode", "dsh"] +AgentName = Literal["claude", "opencode", "workbuddy", "dsh"] Scope = Literal["project", "global"] -# claude/opencode 的生成物布局:(模板文件名, 目标相对路径)。 -# skill 说明书写入官方 skills//SKILL.md(skills 为复数,Claude Code / OpenCode 的 -# 官方发现路径),README 写配置目录根。hooks.md 不再生成(见模块 docstring)。 -SKILL_FILE_LAYOUT = ( - ("skill.md", "skills/mex/SKILL.md"), - ("README.md", "README.md"), -) +# 各 agent 的生成物布局:(模板文件名, 目标相对路径)。 +# claude/opencode 的 base 是配置根目录,skill 写入官方 skills//SKILL.md(skills 为复数, +# Claude Code / OpenCode 的官方发现路径);workbuddy 的 base 本身就是 skill 根目录 +# (~/.workbuddy/skills / ./.workbuddy/skills),skill 直接写入 mex/SKILL.md。 +# README 一律写 base 根目录。hooks.md 不再生成(见模块 docstring)。 +SKILL_FILE_LAYOUT: dict[AgentName, tuple[tuple[str, str], ...]] = { + "claude": (("skill.md", "skills/mex/SKILL.md"), ("README.md", "README.md")), + "opencode": (("skill.md", "skills/mex/SKILL.md"), ("README.md", "README.md")), + "workbuddy": (("skill.md", "mex/SKILL.md"), ("README.md", "README.md")), +} # DSH bundle 的组成文件(独立于 SKILL_FILE_LAYOUT 的清单)。 # index.js = Host half(工具注册);panel.js = Host 面板逻辑(抽取 agent 触发); @@ -47,11 +50,12 @@ class IntegrationResult: written_files: list[str] -# 目标根目录(claude/opencode):global 用 ~ 前缀(Path.home() 展开);project 为相对当前目录。 +# 目标根目录(claude/opencode/workbuddy):global 用 ~ 前缀(Path.home() 展开);project 为相对当前目录。 # dsh 的目标目录单独处理(见 _resolve_base_dir 分支),不在此表内。 AGENT_TARGETS: dict[AgentName, dict[Scope, str]] = { "claude": {"global": "~/.claude", "project": ".claude"}, "opencode": {"global": "~/.config/opencode", "project": ".opencode"}, + "workbuddy": {"global": "~/.workbuddy/skills", "project": ".workbuddy/skills"}, } _PLACEHOLDERS = ( @@ -65,11 +69,11 @@ class IntegrationResult: def integrate(agent: AgentName, scope: Scope) -> IntegrationResult: """生成 agent 集成文件。 - claude/opencode:渲染 hook/skill/README 三件写入 agent 配置目录; + claude/opencode/workbuddy:渲染 skill 说明书 + README 写入 agent 的 skill 发现目录; dsh:复制标准 DSH bundle 目录到目标位置。 Args: - agent: 目标 agent(claude / opencode / dsh)。 + agent: 目标 agent(claude / opencode / workbuddy / dsh)。 scope: 作用域(project 写当前目录,global 写用户目录)。 Returns: @@ -86,12 +90,13 @@ def integrate(agent: AgentName, scope: Scope) -> IntegrationResult: def _generate_skill_files(agent: AgentName, base_dir: Path) -> list[str]: - """渲染 claude/opencode 的 skill 说明书 + README 写入 base_dir。 + """渲染 claude/opencode/workbuddy 的 skill 说明书 + README 写入 base_dir。 - skill.md 落到 ``base_dir/skills/mex/SKILL.md``(官方发现路径),README 落根目录。 + 目标相对路径取自 ``SKILL_FILE_LAYOUT[agent]``(claude/opencode 落到 + ``base_dir/skills/mex/SKILL.md``,workbuddy 落到 ``base_dir/mex/SKILL.md``),README 落根目录。 Args: - agent: 目标 agent(claude / opencode)。 + agent: 目标 agent(claude / opencode / workbuddy)。 base_dir: agent 配置根目录。 Returns: @@ -99,7 +104,7 @@ def _generate_skill_files(agent: AgentName, base_dir: Path) -> list[str]: """ mex_home = get_mex_home() written: list[str] = [] - for template_name, rel_path in SKILL_FILE_LAYOUT: + for template_name, rel_path in SKILL_FILE_LAYOUT[agent]: target = base_dir / rel_path content = _render_template(agent, template_name, base_dir, mex_home) target.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/cli/test_integrate.py b/tests/cli/test_integrate.py index e95fc06..5db670d 100644 --- a/tests/cli/test_integrate.py +++ b/tests/cli/test_integrate.py @@ -40,7 +40,14 @@ def expected_dir(tmp_path: Path, agent: str, scope: str) -> Path: class TestIntegrateCli: - @pytest.mark.parametrize("agent", ["claude", "opencode"]) + # 各 agent 的 skill 相对路径(claude/opencode 走 skills/ 子目录,workbuddy 的 base 即 skill 根)。 + SKILL_REL = { + "claude": "skills/mex/SKILL.md", + "opencode": "skills/mex/SKILL.md", + "workbuddy": "mex/SKILL.md", + } + + @pytest.mark.parametrize("agent", ["claude", "opencode", "workbuddy"]) @pytest.mark.parametrize("scope", ["project", "global"]) def test_success_outputs_paths(self, mex_home, tmp_path, monkeypatch, agent, scope): if scope == "global": @@ -54,7 +61,7 @@ def test_success_outputs_paths(self, mex_home, tmp_path, monkeypatch, agent, sco assert "SKILL.md" in result.stdout and "README.md" in result.stdout assert "Next steps" in result.stdout target = expected_dir(tmp_path, agent, scope) - for rel in ("skills/mex/SKILL.md", "README.md"): + for rel in (self.SKILL_REL[agent], "README.md"): assert (target / rel).exists() assert str(target / rel) in result.stdout @@ -66,6 +73,16 @@ def test_default_scope_is_global(self, mex_home, tmp_path, monkeypatch): assert result.exit_code == 0, result.stderr assert (tmp_path / ".claude" / "skills" / "mex" / "SKILL.md").exists() + def test_workbuddy_global_target_dir(self, mex_home, tmp_path, monkeypatch): + """workbuddy 的 global 目标目录是 ~/.workbuddy/skills(本身就是 skill 根)。""" + fake_home(tmp_path, monkeypatch) + + result = runner.invoke(app, ["integrate", "workbuddy"]) + + assert result.exit_code == 0, result.stderr + assert (tmp_path / ".workbuddy" / "skills" / "mex" / "SKILL.md").exists() + assert not (tmp_path / ".workbuddy" / "skills" / "skills" / "mex").exists() + def test_unknown_agent_exit_1(self, mex_home, tmp_path, monkeypatch): fake_home(tmp_path, monkeypatch) diff --git a/tests/services/test_integrate.py b/tests/services/test_integrate.py index 5306538..f9fd304 100644 --- a/tests/services/test_integrate.py +++ b/tests/services/test_integrate.py @@ -13,7 +13,7 @@ from mex.cli.common import UserError from mex.services.integrate import AGENT_TARGETS, integrate -AGENTS = ("claude", "opencode") +AGENTS = ("claude", "opencode", "workbuddy") SCOPES = ("project", "global") @@ -38,8 +38,13 @@ def target_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, agent: str, scop class TestIntegrate: - # claude/opencode 生成物:skill 说明书(官方 skills/mex/SKILL.md 发现路径)+ README。 - GENERATED_FILES = ("skills/mex/SKILL.md", "README.md") + # 各 agent 生成物:skill 说明书(claude/opencode 走官方 skills/mex/SKILL.md 发现路径, + # workbuddy 的 base 本身就是 skill 根目录 → mex/SKILL.md)+ README。 + GENERATED_FILES = { + "claude": ("skills/mex/SKILL.md", "README.md"), + "opencode": ("skills/mex/SKILL.md", "README.md"), + "workbuddy": ("mex/SKILL.md", "README.md"), + } @pytest.mark.parametrize("agent", AGENTS) @pytest.mark.parametrize("scope", SCOPES) @@ -50,7 +55,7 @@ def test_generates_skill_and_readme(self, mex_home, tmp_path, monkeypatch, agent assert result.agent == agent and result.scope == scope assert len(result.written_files) == 2 - for rel in self.GENERATED_FILES: + for rel in self.GENERATED_FILES[agent]: assert (expected / rel).exists() assert str(expected / rel) in result.written_files @@ -61,7 +66,7 @@ def test_placeholders_replaced(self, mex_home, tmp_path, monkeypatch, agent, sco integrate(agent, scope) - for rel in self.GENERATED_FILES: + for rel in self.GENERATED_FILES[agent]: content = (expected / rel).read_text(encoding="utf-8") assert "{{" not in content, f"{rel} 仍含未替换占位符" assert str(mex_home) in content @@ -75,9 +80,10 @@ def test_idempotent(self, mex_home, tmp_path, monkeypatch, agent): second = integrate(agent, "project") assert first.written_files == second.written_files - for rel in self.GENERATED_FILES: + for rel in self.GENERATED_FILES[agent]: assert (expected / rel).exists() - assert "{{" not in (expected / "skills" / "mex" / "SKILL.md").read_text(encoding="utf-8") + skill_rel = self.GENERATED_FILES[agent][0] + assert "{{" not in (expected / skill_rel).read_text(encoding="utf-8") def test_not_initialized_raises(self, tmp_path, monkeypatch): monkeypatch.setenv("MEX_HOME", str(tmp_path))