Skip to content
Merged
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
12 changes: 9 additions & 3 deletions src/mex/cli/integrate.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
46 changes: 46 additions & 0 deletions src/mex/integration/workbuddy/README.md
Original file line number Diff line number Diff line change
@@ -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/`)即可,不影响既有记忆。
80 changes: 80 additions & 0 deletions src/mex/integration/workbuddy/skill.md
Original file line number Diff line number Diff line change
@@ -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` 可覆盖)。
41 changes: 23 additions & 18 deletions src/mex/services/integrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
"""

Expand All @@ -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/<name>/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/<name>/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 触发);
Expand All @@ -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 = (
Expand All @@ -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:
Expand All @@ -86,20 +90,21 @@ 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:
写入的文件绝对路径清单。
"""
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)
Expand Down
21 changes: 19 additions & 2 deletions tests/cli/test_integrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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

Expand All @@ -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)

Expand Down
20 changes: 13 additions & 7 deletions tests/services/test_integrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand All @@ -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)
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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))
Expand Down
Loading