From d1f9059cc2d43342681d366b8b2d05b410b20b33 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sat, 20 Jun 2026 17:23:16 +0000 Subject: [PATCH] feat(setup): add WorkBuddy integration Add a WorkBuddy setup target that installs the mnemon skill, prompt files, and native command hooks into .workbuddy/ or ~/.workbuddy/. The integration registers SessionStart, UserPromptSubmit, and Stop hooks in settings.json and mirrors the CodeBuddy-compatible hook response shape for Stop. This keeps WorkBuddy separate from the CodeBuddy target because it uses its own config directory. Validated with go test ./internal/setup and go build -o mnemon . --- README.md | 24 ++- cmd/setup.go | 101 +++++++++++- docs/USAGE.md | 3 +- docs/zh/README.md | 16 +- docs/zh/USAGE.md | 3 +- internal/setup/assets/assets.go | 14 +- internal/setup/assets/workbuddy/SKILL.md | 46 ++++++ internal/setup/assets/workbuddy/prime.sh | 22 +++ internal/setup/assets/workbuddy/stop.sh | 31 ++++ .../setup/assets/workbuddy/user_prompt.sh | 3 + internal/setup/detect.go | 46 +++++- internal/setup/workbuddy.go | 141 ++++++++++++++++ internal/setup/workbuddy_test.go | 153 ++++++++++++++++++ 13 files changed, 583 insertions(+), 20 deletions(-) create mode 100644 internal/setup/assets/workbuddy/SKILL.md create mode 100644 internal/setup/assets/workbuddy/prime.sh create mode 100644 internal/setup/assets/workbuddy/stop.sh create mode 100644 internal/setup/assets/workbuddy/user_prompt.sh create mode 100644 internal/setup/workbuddy.go create mode 100644 internal/setup/workbuddy_test.go diff --git a/README.md b/README.md index 86630a40..cde3ac98 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,16 @@ CodeBuddy deploys the mnemon skill, prompt files, and native hooks to `.codebuddy/` or `~/.codebuddy/`. The integration registers `SessionStart`, `UserPromptSubmit`, and `Stop` hooks in `settings.json`. +### [WorkBuddy](https://www.codebuddy.cn/work/) + +```bash +mnemon setup --target workbuddy --yes +``` + +WorkBuddy deploys the mnemon skill, prompt files, and native hooks to +`.workbuddy/` or `~/.workbuddy/`. The integration registers `SessionStart`, +`UserPromptSubmit`, and `Stop` hooks in `settings.json`. + ### [OpenClaw](https://github.com/openclaw/openclaw) ```bash @@ -243,7 +253,7 @@ memory is useful. - **Zero user-side operation** — install once; supported runtimes can use hooks, minimal runtimes can use persistent rules - **LLM-supervised** — the host LLM decides what to remember, update, and forget; no embedded LLM, no API keys -- **Multi-framework support** — Claude Code, Codex, Cursor, TRAE/TRAE Work, Qoder/QoderWork, CodeBuddy, and Hermes Agent (hooks), OpenClaw (plugins), Pi (extensions), Nanobot (skills), and more +- **Multi-framework support** — Claude Code, Codex, Cursor, TRAE/TRAE Work, Qoder/QoderWork, CodeBuddy, WorkBuddy, and Hermes Agent (hooks), OpenClaw (plugins), Pi (extensions), Nanobot (skills), and more - **Markdown-installable harness** — `SKILL.md`, `INSTALL.md`, `GUIDELINE.md`, and four lifecycle reminders - **Four-graph architecture** — temporal, entity, causal, and semantic edges, not just vector similarity - **Intent-native protocol** — three primitives (`remember`, `link`, `recall`) map to the LLM's cognitive vocabulary, not database syntax; structured JSON output with signal transparency @@ -274,6 +284,8 @@ All your local agentic AIs — across sessions and frameworks — sharing one po │ CodeBuddy ────┤ │ + WorkBuddy ────┤ + │ Hermes Agent ─┤ │ OpenClaw ─────┤ @@ -291,11 +303,11 @@ All your local agentic AIs — across sessions and frameworks — sharing one po The foundation is in place: a single `~/.mnemon` database that any agent can read and write. Claude Code, Codex, Cursor, TRAE/TRAE Work, Qoder/QoderWork, -CodeBuddy, and Hermes Agent setup automate hook installation; OpenClaw can use -plugin hooks; Pi integrates via native skills and TypeScript lifecycle -extensions; Nanobot integrates via skill files; NanoClaw integrates via -container skills and volume mounts. The same harness can be installed in any LLM -CLI that supports skills, rules, system prompts, or event hooks. +CodeBuddy, WorkBuddy, and Hermes Agent setup automate hook installation; +OpenClaw can use plugin hooks; Pi integrates via native skills and TypeScript +lifecycle extensions; Nanobot integrates via skill files; NanoClaw integrates +via container skills and volume mounts. The same harness can be installed in any +LLM CLI that supports skills, rules, system prompts, or event hooks. The longer-term direction is a **memory gateway**: protocol decoupled from storage engine. The current SQLite backend is the first adapter; the protocol surface (`remember / link / recall`) can sit on top of PostgreSQL, Neo4j, or any graph database. Agent-side optimization (when to recall, what to remember) and storage-side optimization (indexing, graph algorithms) evolve independently. See [Future Direction](docs/design/08-decisions.md#82-future-direction) for details. diff --git a/cmd/setup.go b/cmd/setup.go index df3128b1..7ca49696 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -22,11 +22,11 @@ var setupCmd = &cobra.Command{ Short: "Deploy mnemon into LLM CLI environments", Long: `Detect installed LLM CLIs and deploy mnemon integration. -By default, installs to project-local config (.claude/, .codex/, .cursor/, .trae/, .qoder/, .codebuddy/, .openclaw/, .nanobot/, .pi/). -Use --global to install to user-wide config (~/.claude/, ~/.codex/, ~/.cursor/, ~/.trae/, ~/.qoder/, ~/.codebuddy/, ~/.openclaw/, ~/.nanobot/workspace/, ~/.pi/agent/). +By default, installs to project-local config (.claude/, .codex/, .cursor/, .trae/, .qoder/, .codebuddy/, .workbuddy/, .openclaw/, .nanobot/, .pi/). +Use --global to install to user-wide config (~/.claude/, ~/.codex/, ~/.cursor/, ~/.trae/, ~/.qoder/, ~/.codebuddy/, ~/.workbuddy/, ~/.openclaw/, ~/.nanobot/workspace/, ~/.pi/agent/). Hermes Agent and QoderWork use their native user config at ~/.hermes/ and ~/.qoderwork/. -Supported environments: Claude Code, Codex, Cursor, Trae, Qoder, QoderWork, CodeBuddy, OpenClaw, Nanobot, Pi, Hermes Agent. +Supported environments: Claude Code, Codex, Cursor, Trae, Qoder, QoderWork, CodeBuddy, WorkBuddy, OpenClaw, Nanobot, Pi, Hermes Agent. Examples: mnemon setup # Interactive: project-local install @@ -37,6 +37,7 @@ Examples: mnemon setup --target qoder # Non-interactive: Qoder skill and hooks mnemon setup --target qoderwork # Non-interactive: QoderWork skill and hooks mnemon setup --target codebuddy # Non-interactive: CodeBuddy skill and hooks + mnemon setup --target workbuddy # Non-interactive: WorkBuddy skill and hooks mnemon setup --target hermes # Non-interactive: Hermes Agent only mnemon setup --eject # Interactive: remove integrations mnemon setup --eject --target claude-code # Non-interactive: remove Claude Code only @@ -45,7 +46,7 @@ Examples: } func init() { - setupCmd.Flags().StringVar(&setupTarget, "target", "", "target environment (claude-code, codex, cursor, trae, qoder, qoderwork, codebuddy, openclaw, nanobot, pi, hermes)") + setupCmd.Flags().StringVar(&setupTarget, "target", "", "target environment (claude-code, codex, cursor, trae, qoder, qoderwork, codebuddy, workbuddy, openclaw, nanobot, pi, hermes)") setupCmd.Flags().BoolVar(&setupEject, "eject", false, "remove mnemon integrations") setupCmd.Flags().BoolVar(&setupYes, "yes", false, "auto-confirm all prompts (CI-friendly)") setupCmd.Flags().BoolVar(&setupGlobal, "global", false, "install to user-wide config instead of project-local config") @@ -53,8 +54,8 @@ func init() { } func runSetup(cmd *cobra.Command, args []string) error { - if setupTarget != "" && setupTarget != "claude-code" && setupTarget != "codex" && setupTarget != "cursor" && setupTarget != "trae" && setupTarget != "qoder" && setupTarget != "qoderwork" && setupTarget != "codebuddy" && setupTarget != "openclaw" && setupTarget != "nanobot" && setupTarget != "pi" && setupTarget != "hermes" { - return fmt.Errorf("invalid target %q (must be claude-code, codex, cursor, trae, qoder, qoderwork, codebuddy, openclaw, nanobot, pi, or hermes)", setupTarget) + if setupTarget != "" && setupTarget != "claude-code" && setupTarget != "codex" && setupTarget != "cursor" && setupTarget != "trae" && setupTarget != "qoder" && setupTarget != "qoderwork" && setupTarget != "codebuddy" && setupTarget != "workbuddy" && setupTarget != "openclaw" && setupTarget != "nanobot" && setupTarget != "pi" && setupTarget != "hermes" { + return fmt.Errorf("invalid target %q (must be claude-code, codex, cursor, trae, qoder, qoderwork, codebuddy, workbuddy, openclaw, nanobot, pi, or hermes)", setupTarget) } envs := setup.DetectEnvironments(setupGlobal) @@ -90,7 +91,7 @@ func runInstallFlow(envs []setup.Environment) error { if len(detected) == 0 { fmt.Println("\nNo supported LLM CLI environments detected.") - fmt.Println("Install Claude Code, Codex, Cursor, Trae, Qoder, QoderWork, CodeBuddy, OpenClaw, Nanobot, Pi, or Hermes Agent, then run 'mnemon setup' again.") + fmt.Println("Install Claude Code, Codex, Cursor, Trae, Qoder, QoderWork, CodeBuddy, WorkBuddy, OpenClaw, Nanobot, Pi, or Hermes Agent, then run 'mnemon setup' again.") return nil } @@ -144,6 +145,8 @@ func installEnv(env *setup.Environment) error { err = installQoderWork(env) case "codebuddy": err = installCodeBuddy(env) + case "workbuddy": + err = installWorkBuddy(env) case "openclaw": err = installOpenClaw(env) case "nanobot": @@ -758,6 +761,83 @@ func installCodeBuddy(env *setup.Environment) error { return nil } +// ─── WorkBuddy ────────────────────────────────────────────────────── + +func installWorkBuddy(env *setup.Environment) error { + configDir := env.ConfigDir + + if !setupGlobal && !setupYes && setup.IsInteractive() { + home := setup.HomeDir() + localDir := ".workbuddy" + globalDir := home + "/.workbuddy" + idx := setup.SelectOne("Install scope", + []string{ + fmt.Sprintf("Local — this project only (%s/)", localDir), + fmt.Sprintf("Global — all projects (%s/)", globalDir), + }, 0) + if idx == 1 { + configDir = globalDir + } else { + configDir = localDir + } + } + + fmt.Printf("\nSetting up WorkBuddy (%s)...\n", configDir) + + fmt.Println("\n[1/3] Skill") + if path, err := setup.WorkBuddyWriteSkill(configDir); err != nil { + setup.StatusError(0, 0, "Skill", err) + return err + } else { + setup.StatusOK(0, 0, "Skill", path) + } + + fmt.Println("\n[2/3] Prompts") + var promptPath string + if path, err := setup.WritePromptFiles(); err != nil { + setup.StatusError(0, 0, "Prompts", err) + return err + } else { + setup.StatusOK(0, 0, "Prompts", path) + promptPath = path + } + + fmt.Println("\n[3/3] Hooks") + for _, hook := range []struct { + label string + filename string + content []byte + }{ + {"Hook: prime", "prime.sh", assets.WorkBuddyPrimeHook}, + {"Hook: remind", "user_prompt.sh", assets.WorkBuddyUserPromptHook}, + {"Hook: nudge", "stop.sh", assets.WorkBuddyStopHook}, + } { + if path, err := setup.WorkBuddyWriteHook(configDir, hook.filename, hook.content); err != nil { + setup.StatusError(0, 0, hook.label, err) + return err + } else { + setup.StatusOK(0, 0, hook.label, path) + } + } + if path, err := setup.WorkBuddyRegisterHooks(configDir); err != nil { + setup.StatusError(0, 0, "Settings", err) + return err + } else { + setup.StatusUpdated(0, 0, "Settings", path) + } + + fmt.Println() + fmt.Println("Setup complete!") + fmt.Printf(" Skill %s/skills/mnemon/SKILL.md\n", configDir) + fmt.Printf(" Hooks %s/settings.json (SessionStart, UserPromptSubmit, Stop)\n", configDir) + fmt.Printf(" Prompts %s/ (guide.md, skill.md)\n", promptPath) + fmt.Println() + fmt.Println("Restart WorkBuddy to activate the mnemon skill and hooks.") + fmt.Println("Run 'mnemon setup --eject --target workbuddy' to remove.") + + return nil +} + // ─── OpenClaw ─────────────────────────────────────────────────────── func installOpenClaw(env *setup.Environment) error { @@ -1220,6 +1300,13 @@ func ejectEnv(env *setup.Environment) error { return errs[0] } + case "workbuddy": + errs := setup.WorkBuddyEject(env.ConfigDir) + ejectMarkdown("AGENTS.md", "Remove memory guidance from ./AGENTS.md?") + if len(errs) > 0 { + return errs[0] + } + case "openclaw": errs := setup.OpenClawEject(env.ConfigDir) ejectMarkdown("AGENTS.md", "Remove memory guidance from ./AGENTS.md?") diff --git a/docs/USAGE.md b/docs/USAGE.md index 8f30a41d..315f2a78 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -35,6 +35,7 @@ mnemon setup --target trae mnemon setup --target qoder mnemon setup --target qoderwork mnemon setup --target codebuddy +mnemon setup --target workbuddy mnemon setup --target openclaw mnemon setup --target pi mnemon setup --target nanobot --global @@ -51,7 +52,7 @@ mnemon setup --eject --target claude-code | Flag | Default | Description | |---|---|---| | `--global` | `false` | Install to user-wide config instead of project-local (recommended for Nanobot: installs to `~/.nanobot/workspace/`; Pi installs to `~/.pi/agent/`; Hermes installs to `~/.hermes/`; QoderWork installs to `~/.qoderwork/`) | -| `--target ` | (auto-detect) | Target environment: `claude-code`, `codex`, `cursor`, `trae`, `qoder`, `qoderwork`, `codebuddy`, `openclaw`, `nanobot`, `pi`, or `hermes` | +| `--target ` | (auto-detect) | Target environment: `claude-code`, `codex`, `cursor`, `trae`, `qoder`, `qoderwork`, `codebuddy`, `workbuddy`, `openclaw`, `nanobot`, `pi`, or `hermes` | | `--eject` | `false` | Remove mnemon integrations | | `--yes` | `false` | Auto-confirm all prompts | diff --git a/docs/zh/README.md b/docs/zh/README.md index 9b8075c9..a38d0d90 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -123,6 +123,16 @@ CodeBuddy 会将 mnemon skill、prompt 文件和原生 hooks 部署到 `.codebud 或 `~/.codebuddy/`。该集成会在 `settings.json` 中注册 `SessionStart`、 `UserPromptSubmit` 和 `Stop` hooks。 +### [WorkBuddy](https://www.codebuddy.cn/work/) + +```bash +mnemon setup --target workbuddy --yes +``` + +WorkBuddy 会将 mnemon skill、prompt 文件和原生 hooks 部署到 `.workbuddy/` +或 `~/.workbuddy/`。该集成会在 `settings.json` 中注册 `SessionStart`、 +`UserPromptSubmit` 和 `Stop` hooks。 + ### [OpenClaw](https://github.com/openclaw/openclaw) ```bash @@ -206,7 +216,7 @@ Agent 工作,并且只在有用时调用 Mnemon - **零用户操作** — 安装一次;支持 hook 的 runtime 可用 hook,minimal runtime 可用持久规则 - **LLM 监督式** — 宿主 LLM 主动决定记什么、更新什么、遗忘什么;无内嵌 LLM,无 API 密钥 -- **多框架支持** — Claude Code、Codex、Cursor、TRAE/TRAE Work、Qoder/QoderWork、CodeBuddy 和 Hermes Agent(hooks)、OpenClaw(plugins)、Pi(extensions)、Nanobot(skills)等 +- **多框架支持** — Claude Code、Codex、Cursor、TRAE/TRAE Work、Qoder/QoderWork、CodeBuddy、WorkBuddy 和 Hermes Agent(hooks)、OpenClaw(plugins)、Pi(extensions)、Nanobot(skills)等 - **Markdown 可安装 harness** — `SKILL.md`、`INSTALL.md`、`GUIDELINE.md` 和四个生命周期提醒 - **四图架构** — 时序、实体、因果、语义四种边,不仅仅是向量相似度 - **意图原生协议** — 三个原语(`remember`、`link`、`recall`)映射到 LLM 的认知词汇而非数据库语法;结构化 JSON 输出,带信号透明度 @@ -236,6 +246,8 @@ Agent 工作,并且只在有用时调用 Mnemon │ CodeBuddy ────┤ │ + WorkBuddy ────┤ + │ Hermes Agent ─┤ │ OpenClaw ─────┤ @@ -251,7 +263,7 @@ Agent 工作,并且只在有用时调用 Mnemon Gemini CLI ───┘ ``` -基础已就绪:一个 `~/.mnemon` 数据库,任何 agent 都可以读写。Claude Code、Codex、Cursor、TRAE/TRAE Work、Qoder/QoderWork、CodeBuddy 和 Hermes Agent setup 可自动安装 hook;OpenClaw 可以使用 plugin hooks;Pi 通过原生 skill 和 TypeScript lifecycle extension 集成;Nanobot 通过 skill 文件集成;NanoClaw 通过容器技能和卷挂载集成。同一个 harness 可以安装到任何支持 skill、rule、system prompt 或 event hook 的 LLM CLI。 +基础已就绪:一个 `~/.mnemon` 数据库,任何 agent 都可以读写。Claude Code、Codex、Cursor、TRAE/TRAE Work、Qoder/QoderWork、CodeBuddy、WorkBuddy 和 Hermes Agent setup 可自动安装 hook;OpenClaw 可以使用 plugin hooks;Pi 通过原生 skill 和 TypeScript lifecycle extension 集成;Nanobot 通过 skill 文件集成;NanoClaw 通过容器技能和卷挂载集成。同一个 harness 可以安装到任何支持 skill、rule、system prompt 或 event hook 的 LLM CLI。 更长远的方向是**记忆网关**:协议层与存储引擎解耦。当前 SQLite 后端是第一个适配器;协议面(`remember / link / recall`)可运行在 PostgreSQL、Neo4j 或任何图数据库之上。Agent 侧优化(何时召回、记什么)与存储侧优化(索引、图算法)独立演进。详见[未来方向](design/08-decisions.md#82-未来方向)。 diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 2e13887f..d9c8534d 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -35,6 +35,7 @@ mnemon setup --target trae mnemon setup --target qoder mnemon setup --target qoderwork mnemon setup --target codebuddy +mnemon setup --target workbuddy mnemon setup --target openclaw mnemon setup --target pi mnemon setup --target nanobot --global @@ -51,7 +52,7 @@ mnemon setup --eject --target claude-code | 标志 | 默认值 | 说明 | |---|---|---| | `--global` | `false` | 安装到用户级配置而非项目本地(Nanobot 推荐安装到 `~/.nanobot/workspace/`;Pi 安装到 `~/.pi/agent/`;Hermes 安装到 `~/.hermes/`;QoderWork 安装到 `~/.qoderwork/`) | -| `--target ` | (自动检测) | 目标环境:`claude-code`、`codex`、`cursor`、`trae`、`qoder`、`qoderwork`、`codebuddy`、`openclaw`、`nanobot`、`pi` 或 `hermes` | +| `--target ` | (自动检测) | 目标环境:`claude-code`、`codex`、`cursor`、`trae`、`qoder`、`qoderwork`、`codebuddy`、`workbuddy`、`openclaw`、`nanobot`、`pi` 或 `hermes` | | `--eject` | `false` | 移除 mnemon 集成 | | `--yes` | `false` | 自动确认所有提示 | diff --git a/internal/setup/assets/assets.go b/internal/setup/assets/assets.go index 10336569..ca8362ad 100644 --- a/internal/setup/assets/assets.go +++ b/internal/setup/assets/assets.go @@ -83,6 +83,18 @@ var CodeBuddyUserPromptHook []byte //go:embed codebuddy/stop.sh var CodeBuddyStopHook []byte +//go:embed workbuddy/SKILL.md +var WorkBuddySkill []byte + +//go:embed workbuddy/prime.sh +var WorkBuddyPrimeHook []byte + +//go:embed workbuddy/user_prompt.sh +var WorkBuddyUserPromptHook []byte + +//go:embed workbuddy/stop.sh +var WorkBuddyStopHook []byte + //go:embed openclaw/SKILL.md var OpenClawSkill []byte @@ -133,5 +145,5 @@ var HermesCompactHook []byte // All returns the embedded filesystem for inspection. // -//go:embed claude codex cursor trae qoder qoderwork codebuddy openclaw nanoclaw nanobot pi hermes +//go:embed claude codex cursor trae qoder qoderwork codebuddy workbuddy openclaw nanoclaw nanobot pi hermes var All embed.FS diff --git a/internal/setup/assets/workbuddy/SKILL.md b/internal/setup/assets/workbuddy/SKILL.md new file mode 100644 index 00000000..e0988b4d --- /dev/null +++ b/internal/setup/assets/workbuddy/SKILL.md @@ -0,0 +1,46 @@ +--- +name: mnemon +description: Persistent memory CLI for WorkBuddy. Store facts, recall past knowledge, link related memories, manage lifecycle. +--- + +# mnemon + +## Workflow + +1. **Remember**: `mnemon remember "" --cat --imp <1-5> --entities "e1,e2" --source agent` + - Diff is built in: duplicates are skipped, conflicts are auto-replaced. + - Output includes `action` (added/updated/skipped), `semantic_candidates`, and `causal_candidates`. +2. **Link** (evaluate candidates from step 1 using judgment): + - Review `causal_candidates`: link only when the memories are genuinely causally related. + - Review `semantic_candidates`: high `similarity` alone is not enough; skip unrelated keyword matches. + - Syntax: `mnemon link --type --weight <0-1> [--meta '']` +3. **Recall**: `mnemon recall "" --limit 10` + +## Commands + +```bash +mnemon remember "" --cat --imp <1-5> --entities "e1,e2" --source agent +mnemon link --type --weight <0-1> [--meta ''] +mnemon recall "" --limit 10 +mnemon search "" --limit 10 +mnemon import --dry-run +mnemon import +mnemon forget +mnemon related --edge causal +mnemon gc --threshold 0.4 +mnemon gc --keep +mnemon status +mnemon log +mnemon store list +mnemon store create +mnemon store set +mnemon store remove +``` + +## Guardrails + +- Use memory only when it can materially improve continuity or task quality. +- Do not store secrets, passwords, tokens, private keys, or short-lived operational noise. +- Categories: `preference`, `decision`, `insight`, `fact`, `context` +- Edge types: `temporal`, `semantic`, `causal`, `entity` +- Max 8,000 chars per insight. diff --git a/internal/setup/assets/workbuddy/prime.sh b/internal/setup/assets/workbuddy/prime.sh new file mode 100644 index 00000000..694a2d0a --- /dev/null +++ b/internal/setup/assets/workbuddy/prime.sh @@ -0,0 +1,22 @@ +#!/bin/bash +PROMPT_DIR="${MNEMON_DATA_DIR:-$HOME/.mnemon}/prompt" +if [ ! -f "${PROMPT_DIR}/guide.md" ] && [ -f "${HOME}/.mnemon/prompt/guide.md" ]; then + PROMPT_DIR="${HOME}/.mnemon/prompt" +fi + +if ! command -v mnemon >/dev/null 2>&1; then + echo "[mnemon] Warning: mnemon not found in PATH." + [ -f "${PROMPT_DIR}/guide.md" ] && cat "${PROMPT_DIR}/guide.md" + exit 0 +fi + +STATS=$(mnemon status 2>/dev/null) +if [ -n "$STATS" ]; then + INSIGHTS=$(echo "$STATS" | sed -n 's/.*"total_insights": *\([0-9]*\).*/\1/p' | head -1) + EDGES=$(echo "$STATS" | sed -n 's/.*"edge_count": *\([0-9]*\).*/\1/p' | head -1) + echo "[mnemon] Memory active (${INSIGHTS:-0} insights, ${EDGES:-0} edges)." +else + echo "[mnemon] Memory active." +fi + +[ -f "${PROMPT_DIR}/guide.md" ] && cat "${PROMPT_DIR}/guide.md" diff --git a/internal/setup/assets/workbuddy/stop.sh b/internal/setup/assets/workbuddy/stop.sh new file mode 100644 index 00000000..6821acac --- /dev/null +++ b/internal/setup/assets/workbuddy/stop.sh @@ -0,0 +1,31 @@ +#!/bin/bash +INPUT=$(cat) + +if echo "$INPUT" | grep -q '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then + exit 0 +fi + +SESSION_ID=$(echo "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) +if [ -z "$SESSION_ID" ]; then + SESSION_ID=$(echo "$INPUT" | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1 | sed 's/[^A-Za-z0-9_.-]/_/g') +fi +if [ -z "$SESSION_ID" ]; then + SESSION_ID="unknown" +fi + +STATE_DIR="${MNEMON_DATA_DIR:-$HOME/.mnemon}/hooks" +STATE_FILE="${STATE_DIR}/workbuddy-stop-${SESSION_ID}.seen" +mkdir -p "$STATE_DIR" 2>/dev/null || true + +if [ -f "$STATE_FILE" ]; then + rm -f "$STATE_FILE" 2>/dev/null || true + exit 0 +fi + +touch "$STATE_FILE" 2>/dev/null || true +cat <<'JSON' +{ + "continue": false, + "reason": "[mnemon] Before stopping, evaluate whether this exchange contains durable preferences, decisions, insights, facts, or context worth remembering. If yes, run mnemon remember/link; if no, state that no memory update is needed, then finish." +} +JSON diff --git a/internal/setup/assets/workbuddy/user_prompt.sh b/internal/setup/assets/workbuddy/user_prompt.sh new file mode 100644 index 00000000..6e5d6d2e --- /dev/null +++ b/internal/setup/assets/workbuddy/user_prompt.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cat >/dev/null || true +echo "[mnemon] Evaluate: recall needed? After responding, evaluate: remember needed?" diff --git a/internal/setup/detect.go b/internal/setup/detect.go index 2fc131c7..95b41ae3 100644 --- a/internal/setup/detect.go +++ b/internal/setup/detect.go @@ -9,8 +9,8 @@ import ( // Environment describes a detected LLM CLI environment. type Environment struct { - Name string // "claude-code", "codex", "cursor", "trae", "qoder", "qoderwork", "codebuddy", "openclaw", "nanobot", "pi", "hermes" - Display string // "Claude Code", "Codex", "Cursor", "Trae", "Qoder", "QoderWork", "CodeBuddy", "OpenClaw", "Nanobot", "Pi", "Hermes Agent" + Name string // "claude-code", "codex", "cursor", "trae", "qoder", "qoderwork", "codebuddy", "workbuddy", "openclaw", "nanobot", "pi", "hermes" + Display string // "Claude Code", "Codex", "Cursor", "Trae", "Qoder", "QoderWork", "CodeBuddy", "WorkBuddy", "OpenClaw", "Nanobot", "Pi", "Hermes Agent" Detected bool // CLI binary or global config dir found BinPath string // exec.LookPath result Installed bool // mnemon integration present at ConfigDir @@ -36,6 +36,7 @@ func DetectEnvironments(global bool) []Environment { detectQoder(global), detectQoderWork(), detectCodeBuddy(global), + detectWorkBuddy(global), detectOpenClaw(global), detectNanobot(global), detectPi(global), @@ -319,6 +320,47 @@ func detectCodeBuddy(global bool) Environment { return env } +func detectWorkBuddy(global bool) Environment { + home := HomeDir() + globalDir := filepath.Join(home, ".workbuddy") + localDir := ".workbuddy" + + configDir := localDir + if global { + configDir = globalDir + } + + env := Environment{ + Name: "workbuddy", + Display: "WorkBuddy", + ConfigDir: configDir, + } + + if binPath, err := exec.LookPath("workbuddy"); err == nil { + env.Detected = true + env.BinPath = binPath + } + if _, err := os.Stat(globalDir); err == nil { + env.Detected = true + } + + skillPath := filepath.Join(configDir, "skills", "mnemon", "SKILL.md") + settingsPath := filepath.Join(configDir, "settings.json") + if _, err := os.Stat(skillPath); err == nil { + env.Installed = true + } else if data, err := ReadJSONFile(settingsPath); err == nil && containsMnemon(data) { + env.Installed = true + } + + if env.BinPath != "" { + if out, err := exec.Command(env.BinPath, "--version").Output(); err == nil { + env.Version = cleanVersion(strings.TrimSpace(string(out))) + } + } + + return env +} + func detectOpenClaw(global bool) Environment { home := HomeDir() globalDir := filepath.Join(home, ".openclaw") diff --git a/internal/setup/workbuddy.go b/internal/setup/workbuddy.go new file mode 100644 index 00000000..25f1d36a --- /dev/null +++ b/internal/setup/workbuddy.go @@ -0,0 +1,141 @@ +package setup + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/mnemon-dev/mnemon/internal/setup/assets" +) + +// WorkBuddyWriteSkill writes the mnemon skill to the WorkBuddy skills directory. +func WorkBuddyWriteSkill(configDir string) (string, error) { + skillDir := filepath.Join(configDir, "skills", "mnemon") + if err := os.MkdirAll(skillDir, 0755); err != nil { + return "", err + } + skillPath := filepath.Join(skillDir, "SKILL.md") + if err := os.WriteFile(skillPath, assets.WorkBuddySkill, 0644); err != nil { + return "", err + } + return skillPath, nil +} + +// WorkBuddyWriteHook writes a hook script to the WorkBuddy hooks directory. +func WorkBuddyWriteHook(configDir, filename string, content []byte) (string, error) { + hooksDir := filepath.Join(configDir, "hooks", "mnemon") + if err := os.MkdirAll(hooksDir, 0755); err != nil { + return "", err + } + hookPath := filepath.Join(hooksDir, filename) + if err := os.WriteFile(hookPath, content, 0755); err != nil { + return "", err + } + return hookPath, nil +} + +// WorkBuddyRegisterHooks registers Mnemon lifecycle hooks in settings.json. +func WorkBuddyRegisterHooks(configDir string) (string, error) { + hooksDir := filepath.Join(configDir, "hooks", "mnemon") + absHooksDir, err := filepath.Abs(hooksDir) + if err != nil { + return "", err + } + settingsPath := filepath.Join(configDir, "settings.json") + data, err := ReadJSONFile(settingsPath) + if err != nil { + return "", err + } + addWorkBuddyHooks(data, absHooksDir) + if err := WriteJSONFile(settingsPath, data); err != nil { + return "", err + } + return settingsPath, nil +} + +// WorkBuddyEject removes mnemon skill and hooks from the given WorkBuddy config dir. +func WorkBuddyEject(configDir string) []error { + var errs []error + + fmt.Printf("\nRemoving WorkBuddy integration (%s)...\n", configDir) + + hooksDir := filepath.Join(configDir, "hooks", "mnemon") + if err := os.RemoveAll(hooksDir); err != nil { + StatusError(1, 3, "Hooks", err) + errs = append(errs, err) + } else { + StatusOK(1, 3, "Hooks", hooksDir+" removed") + } + removeIfEmpty(filepath.Join(configDir, "hooks")) + + settingsPath := filepath.Join(configDir, "settings.json") + data, err := ReadJSONFile(settingsPath) + if err != nil { + StatusError(2, 3, "Settings", err) + errs = append(errs, err) + } else { + removeWorkBuddyHooks(data) + if err := WriteOrRemoveJSONFile(settingsPath, data); err != nil { + StatusError(2, 3, "Settings", err) + errs = append(errs, err) + } else { + StatusOK(2, 3, "Settings", settingsPath+" cleaned") + } + } + + skillDir := filepath.Join(configDir, "skills", "mnemon") + if err := os.RemoveAll(skillDir); err != nil { + StatusError(3, 3, "Skill", err) + errs = append(errs, err) + } else { + StatusOK(3, 3, "Skill", skillDir+" removed") + } + removeIfEmpty(filepath.Join(configDir, "skills")) + removeIfEmpty(configDir) + + return errs +} + +func addWorkBuddyHooks(data map[string]interface{}, hooksDir string) { + removeWorkBuddyHooks(data) + hooks := ensureHooksMap(data) + + addWorkBuddyHook(hooks, "SessionStart", filepath.Join(hooksDir, "prime.sh")) + addWorkBuddyHook(hooks, "UserPromptSubmit", filepath.Join(hooksDir, "user_prompt.sh")) + addWorkBuddyHook(hooks, "Stop", filepath.Join(hooksDir, "stop.sh")) +} + +func addWorkBuddyHook(hooks map[string]interface{}, event, command string) { + entry := map[string]interface{}{ + "hooks": []interface{}{ + map[string]interface{}{ + "type": "command", + "command": command, + }, + }, + } + arr, _ := hooks[event].([]interface{}) + hooks[event] = append(arr, entry) +} + +func removeWorkBuddyHooks(data map[string]interface{}) { + hooks, ok := data["hooks"].(map[string]interface{}) + if !ok { + return + } + for _, key := range []string{"SessionStart", "UserPromptSubmit", "Stop", "PreToolUse", "PostToolUse", "Notification", "PreCompact", "SessionEnd", "SubagentStop"} { + arr, ok := hooks[key].([]interface{}) + if !ok { + continue + } + filtered := filterHookArray(arr) + if len(filtered) == 0 { + delete(hooks, key) + } else { + hooks[key] = filtered + } + } + if len(hooks) == 0 { + delete(data, "hooks") + } +} diff --git a/internal/setup/workbuddy_test.go b/internal/setup/workbuddy_test.go new file mode 100644 index 00000000..0484b5ef --- /dev/null +++ b/internal/setup/workbuddy_test.go @@ -0,0 +1,153 @@ +package setup + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWorkBuddyWriteSkill(t *testing.T) { + dir := t.TempDir() + + skillPath, err := WorkBuddyWriteSkill(dir) + if err != nil { + t.Fatalf("write skill: %v", err) + } + if skillPath != filepath.Join(dir, "skills", "mnemon", "SKILL.md") { + t.Fatalf("skill path = %q", skillPath) + } + data, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("read skill: %v", err) + } + if !strings.Contains(string(data), "WorkBuddy") { + t.Fatalf("workbuddy skill should mention WorkBuddy: %s", string(data)) + } +} + +func TestWorkBuddyWriteHook(t *testing.T) { + dir := t.TempDir() + + hookPath, err := WorkBuddyWriteHook(dir, "prime.sh", []byte("#!/bin/bash\n")) + if err != nil { + t.Fatalf("write hook: %v", err) + } + if hookPath != filepath.Join(dir, "hooks", "mnemon", "prime.sh") { + t.Fatalf("hook path = %q", hookPath) + } + info, err := os.Stat(hookPath) + if err != nil { + t.Fatalf("stat hook: %v", err) + } + if info.Mode().Perm() != 0755 { + t.Fatalf("hook permissions = %v, want 0755", info.Mode().Perm()) + } +} + +func TestWorkBuddyRegisterHooksPreservesUnrelatedConfig(t *testing.T) { + dir := t.TempDir() + settingsPath := filepath.Join(dir, "settings.json") + if err := os.WriteFile(settingsPath, []byte(`{ + "hooks": { + "SessionStart": [ + {"hooks": [{"type": "command", "command": "/old/mnemon/prime.sh"}]}, + {"hooks": [{"type": "command", "command": "/keep/custom.sh"}]} + ], + "Stop": [ + {"hooks": [{"type": "command", "command": "/old/mnemon/stop.sh"}]} + ] + }, + "other": true +}`), 0644); err != nil { + t.Fatalf("write settings: %v", err) + } + + if _, err := WorkBuddyRegisterHooks(dir); err != nil { + t.Fatalf("register hooks: %v", err) + } + + data, err := ReadJSONFile(settingsPath) + if err != nil { + t.Fatalf("read settings: %v", err) + } + if data["other"] != true { + t.Fatalf("unrelated setting should be preserved: %#v", data) + } + hooks := data["hooks"].(map[string]any) + sessionStart := hooks["SessionStart"].([]any) + if len(sessionStart) != 2 { + t.Fatalf("expected custom hook plus new prime hook: %#v", sessionStart) + } + if !strings.Contains(sessionStart[1].(map[string]any)["hooks"].([]any)[0].(map[string]any)["command"].(string), "hooks/mnemon/prime.sh") { + t.Fatalf("expected new prime hook, got %#v", sessionStart[1]) + } + if _, ok := hooks["UserPromptSubmit"]; !ok { + t.Fatalf("user prompt hook should be registered: %#v", hooks) + } + stop := hooks["Stop"].([]any) + if len(stop) != 1 { + t.Fatalf("expected one stop hook: %#v", stop) + } + if _, ok := stop[0].(map[string]any)["loop_limit"]; ok { + t.Fatalf("workbuddy hook schema should not include loop_limit: %#v", stop[0]) + } +} + +func TestWorkBuddyEjectRemovesOnlyMnemonFilesAndHooks(t *testing.T) { + dir := t.TempDir() + if _, err := WorkBuddyWriteSkill(dir); err != nil { + t.Fatalf("write skill: %v", err) + } + if _, err := WorkBuddyWriteHook(dir, "prime.sh", []byte("#!/bin/bash\n")); err != nil { + t.Fatalf("write hook: %v", err) + } + if _, err := WorkBuddyRegisterHooks(dir); err != nil { + t.Fatalf("register hooks: %v", err) + } + customSkillDir := filepath.Join(dir, "skills", "custom") + if err := os.MkdirAll(customSkillDir, 0755); err != nil { + t.Fatalf("create custom skill: %v", err) + } + settingsPath := filepath.Join(dir, "settings.json") + data, err := ReadJSONFile(settingsPath) + if err != nil { + t.Fatalf("read settings: %v", err) + } + hooks := data["hooks"].(map[string]any) + hooks["SessionStart"] = append(hooks["SessionStart"].([]any), map[string]any{ + "hooks": []any{map[string]any{"type": "command", "command": "/keep/custom.sh"}}, + }) + if err := WriteJSONFile(settingsPath, data); err != nil { + t.Fatalf("write settings: %v", err) + } + + errs := WorkBuddyEject(dir) + if len(errs) > 0 { + t.Fatalf("eject errors: %v", errs) + } + if _, err := os.Stat(filepath.Join(dir, "skills", "mnemon")); !os.IsNotExist(err) { + t.Fatalf("mnemon skill should be removed, err=%v", err) + } + if _, err := os.Stat(customSkillDir); err != nil { + t.Fatalf("custom skill should be preserved: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "hooks", "mnemon")); !os.IsNotExist(err) { + t.Fatalf("mnemon hooks should be removed, err=%v", err) + } + data, err = ReadJSONFile(settingsPath) + if err != nil { + t.Fatalf("read settings after eject: %v", err) + } + hooks = data["hooks"].(map[string]any) + sessionStart := hooks["SessionStart"].([]any) + if len(sessionStart) != 1 || containsMnemon(sessionStart[0]) { + t.Fatalf("custom hook should be preserved and mnemon removed: %#v", sessionStart) + } + if _, ok := hooks["UserPromptSubmit"]; ok { + t.Fatalf("user prompt hooks should be removed: %#v", hooks) + } + if _, ok := hooks["Stop"]; ok { + t.Fatalf("stop hooks should be removed: %#v", hooks) + } +}