From 49316bbf9e9386c709eaeb34844131edac8010a5 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sat, 20 Jun 2026 16:19:59 +0000 Subject: [PATCH] feat(setup): add Qoder and QoderWork integrations Add setup targets for Qoder and QoderWork that install the mnemon skill, prompt files, and native command hooks using each host's settings.json configuration. Qoder supports project-local .qoder/ and user-wide ~/.qoder/ installs, while QoderWork uses its documented ~/.qoderwork/ user config. The integration registers SessionStart, UserPromptSubmit, and Stop hooks, preserves unrelated settings, and adds eject coverage plus setup tests. Validated with go test ./internal/setup and go build -o mnemon . --- README.md | 22 ++- cmd/setup.go | 139 ++++++++++++++- docs/USAGE.md | 6 +- docs/zh/README.md | 21 ++- docs/zh/USAGE.md | 6 +- internal/setup/assets/assets.go | 17 +- internal/setup/assets/qoder/SKILL.md | 46 +++++ internal/setup/assets/qoder/prime.sh | 22 +++ internal/setup/assets/qoder/stop.sh | 28 +++ internal/setup/assets/qoder/user_prompt.sh | 3 + internal/setup/assets/qoderwork/SKILL.md | 46 +++++ internal/setup/detect.go | 82 ++++++++- internal/setup/qoder.go | 168 ++++++++++++++++++ internal/setup/qoder_test.go | 194 +++++++++++++++++++++ 14 files changed, 778 insertions(+), 22 deletions(-) create mode 100644 internal/setup/assets/qoder/SKILL.md create mode 100644 internal/setup/assets/qoder/prime.sh create mode 100644 internal/setup/assets/qoder/stop.sh create mode 100644 internal/setup/assets/qoder/user_prompt.sh create mode 100644 internal/setup/assets/qoderwork/SKILL.md create mode 100644 internal/setup/qoder.go create mode 100644 internal/setup/qoder_test.go diff --git a/README.md b/README.md index 1e493797..4d753ffb 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,18 @@ One command deploys the mnemon skill, prompt files, and TRAE native hooks for both TRAE IDE and TRAE Work to `.trae/`. The integration uses `SessionStart`, `UserPromptSubmit`, and `Stop` hooks in `.trae/hooks.json`. +### [Qoder](https://qoder.com/) (QoderWork) + +```bash +mnemon setup --target qoder --yes +mnemon setup --target qoderwork --yes +``` + +Qoder deploys the mnemon skill, prompt files, and native hooks to `.qoder/` +or `~/.qoder/`. QoderWork uses its native user config at `~/.qoderwork/`. +Both integrations register `SessionStart`, `UserPromptSubmit`, and `Stop` +hooks in `settings.json`. + ### [OpenClaw](https://github.com/openclaw/openclaw) ```bash @@ -221,7 +233,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, and Cursor (hooks), OpenClaw (plugins), Pi (extensions), Nanobot (skills), and more +- **Multi-framework support** — Claude Code, Codex, Cursor, Qoder, and QoderWork (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 @@ -238,8 +250,14 @@ All your local agentic AIs — across sessions and frameworks — sharing one po ``` Claude Code ──┐ │ + Codex ────────┤ + │ Cursor ───────┤ │ + Qoder ────────┤ + │ + QoderWork ────┤ + │ OpenClaw ─────┤ │ Pi ───────────┤ @@ -254,7 +272,7 @@ 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, and Cursor setup automate hook +read and write. Claude Code, Codex, Cursor, Qoder, and QoderWork 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 diff --git a/cmd/setup.go b/cmd/setup.go index a2fd204a..e348e75b 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/, .openclaw/, .nanobot/, .pi/). -Use --global to install to user-wide config (~/.claude/, ~/.codex/, ~/.cursor/, ~/.trae/, ~/.openclaw/, ~/.nanobot/workspace/, ~/.pi/agent/). -Hermes Agent uses its native user config at ~/.hermes/. +By default, installs to project-local config (.claude/, .codex/, .cursor/, .trae/, .qoder/, .openclaw/, .nanobot/, .pi/). +Use --global to install to user-wide config (~/.claude/, ~/.codex/, ~/.cursor/, ~/.trae/, ~/.qoder/, ~/.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, OpenClaw, Nanobot, Pi, Hermes Agent. +Supported environments: Claude Code, Codex, Cursor, Trae, Qoder, QoderWork, OpenClaw, Nanobot, Pi, Hermes Agent. Examples: mnemon setup # Interactive: project-local install @@ -34,6 +34,8 @@ Examples: mnemon setup --target claude-code # Non-interactive: Claude Code only mnemon setup --target cursor # Non-interactive: Cursor skill only mnemon setup --target trae # Non-interactive: Trae skill and hooks + mnemon setup --target qoder # Non-interactive: Qoder skill and hooks + mnemon setup --target qoderwork # Non-interactive: QoderWork 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 @@ -42,7 +44,7 @@ Examples: } func init() { - setupCmd.Flags().StringVar(&setupTarget, "target", "", "target environment (claude-code, codex, cursor, trae, openclaw, nanobot, pi, hermes)") + setupCmd.Flags().StringVar(&setupTarget, "target", "", "target environment (claude-code, codex, cursor, trae, qoder, qoderwork, 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") @@ -50,8 +52,8 @@ func init() { } func runSetup(cmd *cobra.Command, args []string) error { - if setupTarget != "" && setupTarget != "claude-code" && setupTarget != "codex" && setupTarget != "cursor" && setupTarget != "trae" && setupTarget != "openclaw" && setupTarget != "nanobot" && setupTarget != "pi" && setupTarget != "hermes" { - return fmt.Errorf("invalid target %q (must be claude-code, codex, cursor, trae, openclaw, nanobot, pi, or hermes)", setupTarget) + if setupTarget != "" && setupTarget != "claude-code" && setupTarget != "codex" && setupTarget != "cursor" && setupTarget != "trae" && setupTarget != "qoder" && setupTarget != "qoderwork" && setupTarget != "openclaw" && setupTarget != "nanobot" && setupTarget != "pi" && setupTarget != "hermes" { + return fmt.Errorf("invalid target %q (must be claude-code, codex, cursor, trae, qoder, qoderwork, openclaw, nanobot, pi, or hermes)", setupTarget) } envs := setup.DetectEnvironments(setupGlobal) @@ -87,7 +89,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, OpenClaw, Nanobot, Pi, or Hermes Agent, then run 'mnemon setup' again.") + fmt.Println("Install Claude Code, Codex, Cursor, Trae, Qoder, QoderWork, OpenClaw, Nanobot, Pi, or Hermes Agent, then run 'mnemon setup' again.") return nil } @@ -135,6 +137,10 @@ func installEnv(env *setup.Environment) error { err = installCursor(env) case "trae": err = installTrae(env) + case "qoder": + err = installQoder(env) + case "qoderwork": + err = installQoderWork(env) case "openclaw": err = installOpenClaw(env) case "nanobot": @@ -569,6 +575,109 @@ func installTrae(env *setup.Environment) error { return nil } +// ─── Qoder ────────────────────────────────────────────────────────── + +func installQoder(env *setup.Environment) error { + configDir := env.ConfigDir + + if !setupGlobal && !setupYes && setup.IsInteractive() { + home := setup.HomeDir() + localDir := ".qoder" + globalDir := home + "/.qoder" + 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 Qoder (%s)...\n", configDir) + + return installQoderLike( + configDir, + setup.QoderWriteSkill, + setup.QoderRegisterHooks, + "Restart Qoder IDE/CLI to activate the mnemon skill and hooks.", + "Run 'mnemon setup --eject --target qoder' to remove.", + ) +} + +// ─── QoderWork ────────────────────────────────────────────────────── + +func installQoderWork(env *setup.Environment) error { + configDir := env.ConfigDir + + fmt.Printf("\nSetting up QoderWork (%s)...\n", configDir) + + return installQoderLike( + configDir, + setup.QoderWorkWriteSkill, + setup.QoderWorkRegisterHooks, + "Restart QoderWork to activate the mnemon skill and hooks.", + "Run 'mnemon setup --eject --target qoderwork' to remove.", + ) +} + +func installQoderLike(configDir string, writeSkill func(string) (string, error), registerHooks func(string) (string, error), activation, ejectHint string) error { + fmt.Println("\n[1/3] Skill") + if path, err := writeSkill(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.QoderPrimeHook}, + {"Hook: remind", "user_prompt.sh", assets.QoderUserPromptHook}, + {"Hook: nudge", "stop.sh", assets.QoderStopHook}, + } { + if path, err := setup.QoderWriteHook(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 := registerHooks(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(activation) + fmt.Println(ejectHint) + + return nil +} + // ─── OpenClaw ─────────────────────────────────────────────────────── func installOpenClaw(env *setup.Environment) error { @@ -1010,6 +1119,20 @@ func ejectEnv(env *setup.Environment) error { return errs[0] } + case "qoder": + errs := setup.QoderEject(env.ConfigDir) + ejectMarkdown("AGENTS.md", "Remove memory guidance from ./AGENTS.md?") + if len(errs) > 0 { + return errs[0] + } + + case "qoderwork": + errs := setup.QoderWorkEject(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 9f62741c..aa94c625 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -32,6 +32,8 @@ mnemon setup --target claude-code mnemon setup --target codex mnemon setup --target cursor mnemon setup --target trae +mnemon setup --target qoder +mnemon setup --target qoderwork mnemon setup --target openclaw mnemon setup --target pi mnemon setup --target nanobot --global @@ -47,8 +49,8 @@ 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/`) | -| `--target ` | (auto-detect) | Target environment: `claude-code`, `codex`, `cursor`, `trae`, `openclaw`, `nanobot`, `pi`, or `hermes` | +| `--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`, `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 56ee0c70..098388ea 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -92,15 +92,26 @@ mnemon setup `mnemon setup` 自动检测 Claude Code,交互式部署技能文件、钩子和行为引导。启动新会话 — 记忆自动运作。 -### [Trae](https://www.trae.ai/) +### [TRAE](https://www.trae.ai/) (TRAE Work) ```bash mnemon setup --target trae --yes ``` -一条命令将 mnemon skill、prompt 文件和 Trae 原生 hooks 部署到 `.trae/`。 -该集成使用 `.trae/hooks.json` 中的 `SessionStart`、`UserPromptSubmit` 和 -`Stop` hooks。 +一条命令将 mnemon skill、prompt 文件和 TRAE 原生 hooks 部署到 `.trae/`, +同时覆盖 TRAE IDE 和 TRAE Work。该集成使用 `.trae/hooks.json` 中的 +`SessionStart`、`UserPromptSubmit` 和 `Stop` hooks。 + +### [Qoder](https://qoder.com/) (QoderWork) + +```bash +mnemon setup --target qoder --yes +mnemon setup --target qoderwork --yes +``` + +Qoder 会将 mnemon skill、prompt 文件和原生 hooks 部署到 `.qoder/` 或 +`~/.qoder/`。QoderWork 使用原生用户级配置 `~/.qoderwork/`。两者都会在 +`settings.json` 中注册 `SessionStart`、`UserPromptSubmit` 和 `Stop` hooks。 ### [OpenClaw](https://github.com/openclaw/openclaw) @@ -185,7 +196,7 @@ Agent 工作,并且只在有用时调用 Mnemon - **零用户操作** — 安装一次;支持 hook 的 runtime 可用 hook,minimal runtime 可用持久规则 - **LLM 监督式** — 宿主 LLM 主动决定记什么、更新什么、遗忘什么;无内嵌 LLM,无 API 密钥 -- **多框架支持** — Claude Code 和 Codex(hooks)、OpenClaw(plugins)、Pi(extensions)、Nanobot(skills)等 +- **多框架支持** — Claude Code、Codex、Cursor、Qoder 和 QoderWork(hooks)、OpenClaw(plugins)、Pi(extensions)、Nanobot(skills)等 - **Markdown 可安装 harness** — `SKILL.md`、`INSTALL.md`、`GUIDELINE.md` 和四个生命周期提醒 - **四图架构** — 时序、实体、因果、语义四种边,不仅仅是向量相似度 - **意图原生协议** — 三个原语(`remember`、`link`、`recall`)映射到 LLM 的认知词汇而非数据库语法;结构化 JSON 输出,带信号透明度 diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index d5ac2f4e..9053f906 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -32,6 +32,8 @@ mnemon setup --target claude-code mnemon setup --target codex mnemon setup --target cursor mnemon setup --target trae +mnemon setup --target qoder +mnemon setup --target qoderwork mnemon setup --target openclaw mnemon setup --target pi mnemon setup --target nanobot --global @@ -47,8 +49,8 @@ mnemon setup --eject --target claude-code | 标志 | 默认值 | 说明 | |---|---|---| -| `--global` | `false` | 安装到用户级配置而非项目本地(Nanobot 推荐安装到 `~/.nanobot/workspace/`;Pi 安装到 `~/.pi/agent/`;Hermes 安装到 `~/.hermes/`) | -| `--target ` | (自动检测) | 目标环境:`claude-code`、`codex`、`cursor`、`trae`、`openclaw`、`nanobot`、`pi` 或 `hermes` | +| `--global` | `false` | 安装到用户级配置而非项目本地(Nanobot 推荐安装到 `~/.nanobot/workspace/`;Pi 安装到 `~/.pi/agent/`;Hermes 安装到 `~/.hermes/`;QoderWork 安装到 `~/.qoderwork/`) | +| `--target ` | (自动检测) | 目标环境:`claude-code`、`codex`、`cursor`、`trae`、`qoder`、`qoderwork`、`openclaw`、`nanobot`、`pi` 或 `hermes` | | `--eject` | `false` | 移除 mnemon 集成 | | `--yes` | `false` | 自动确认所有提示 | diff --git a/internal/setup/assets/assets.go b/internal/setup/assets/assets.go index a967b2fd..66dd96b4 100644 --- a/internal/setup/assets/assets.go +++ b/internal/setup/assets/assets.go @@ -56,6 +56,21 @@ var TraeUserPromptHook []byte //go:embed trae/stop.sh var TraeStopHook []byte +//go:embed qoder/SKILL.md +var QoderSkill []byte + +//go:embed qoderwork/SKILL.md +var QoderWorkSkill []byte + +//go:embed qoder/prime.sh +var QoderPrimeHook []byte + +//go:embed qoder/user_prompt.sh +var QoderUserPromptHook []byte + +//go:embed qoder/stop.sh +var QoderStopHook []byte + //go:embed openclaw/SKILL.md var OpenClawSkill []byte @@ -106,5 +121,5 @@ var HermesCompactHook []byte // All returns the embedded filesystem for inspection. // -//go:embed claude codex cursor trae openclaw nanoclaw nanobot pi hermes +//go:embed claude codex cursor trae qoder qoderwork openclaw nanoclaw nanobot pi hermes var All embed.FS diff --git a/internal/setup/assets/qoder/SKILL.md b/internal/setup/assets/qoder/SKILL.md new file mode 100644 index 00000000..2dba308d --- /dev/null +++ b/internal/setup/assets/qoder/SKILL.md @@ -0,0 +1,46 @@ +--- +name: mnemon +description: Persistent memory CLI for LLM agents. 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/qoder/prime.sh b/internal/setup/assets/qoder/prime.sh new file mode 100644 index 00000000..694a2d0a --- /dev/null +++ b/internal/setup/assets/qoder/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/qoder/stop.sh b/internal/setup/assets/qoder/stop.sh new file mode 100644 index 00000000..73b72c03 --- /dev/null +++ b/internal/setup/assets/qoder/stop.sh @@ -0,0 +1,28 @@ +#!/bin/bash +INPUT=$(cat) + +MSG=$(echo "$INPUT" | sed -n 's/.*"last_assistant_message"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) +if echo "$MSG" | grep -qiE "mnemon remember|mnemon recall|mnemon link|Stored.*imp="; 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}/qoder-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 +echo "[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." >&2 +exit 2 diff --git a/internal/setup/assets/qoder/user_prompt.sh b/internal/setup/assets/qoder/user_prompt.sh new file mode 100644 index 00000000..6e5d6d2e --- /dev/null +++ b/internal/setup/assets/qoder/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/assets/qoderwork/SKILL.md b/internal/setup/assets/qoderwork/SKILL.md new file mode 100644 index 00000000..d283a4c8 --- /dev/null +++ b/internal/setup/assets/qoderwork/SKILL.md @@ -0,0 +1,46 @@ +--- +name: mnemon +description: Persistent memory CLI for QoderWork. 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/detect.go b/internal/setup/detect.go index d1fd787b..a39d8b28 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", "openclaw", "nanobot", "pi", "hermes" - Display string // "Claude Code", "Codex", "Cursor", "Trae", "OpenClaw", "Nanobot", "Pi", "Hermes Agent" + Name string // "claude-code", "codex", "cursor", "trae", "qoder", "qoderwork", "openclaw", "nanobot", "pi", "hermes" + Display string // "Claude Code", "Codex", "Cursor", "Trae", "Qoder", "QoderWork", "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 @@ -33,6 +33,8 @@ func DetectEnvironments(global bool) []Environment { detectCodex(global), detectCursor(global), detectTrae(global), + detectQoder(global), + detectQoderWork(), detectOpenClaw(global), detectNanobot(global), detectPi(global), @@ -199,6 +201,82 @@ func detectTrae(global bool) Environment { return env } +func detectQoder(global bool) Environment { + home := HomeDir() + globalDir := filepath.Join(home, ".qoder") + localDir := ".qoder" + + configDir := localDir + if global { + configDir = globalDir + } + + env := Environment{ + Name: "qoder", + Display: "Qoder", + ConfigDir: configDir, + } + + if binPath, err := exec.LookPath("qoder"); 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 detectQoderWork() Environment { + home := HomeDir() + configDir := filepath.Join(home, ".qoderwork") + + env := Environment{ + Name: "qoderwork", + Display: "QoderWork", + ConfigDir: configDir, + } + + if binPath, err := exec.LookPath("qoderwork"); err == nil { + env.Detected = true + env.BinPath = binPath + } + if _, err := os.Stat(configDir); 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/qoder.go b/internal/setup/qoder.go new file mode 100644 index 00000000..52d7035d --- /dev/null +++ b/internal/setup/qoder.go @@ -0,0 +1,168 @@ +package setup + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/mnemon-dev/mnemon/internal/setup/assets" +) + +// QoderWriteSkill writes the mnemon skill to the Qoder skills directory. +func QoderWriteSkill(configDir string) (string, error) { + return writeQoderSkill(configDir, assets.QoderSkill) +} + +// QoderWorkWriteSkill writes the mnemon skill to the QoderWork skills directory. +func QoderWorkWriteSkill(configDir string) (string, error) { + return writeQoderSkill(configDir, assets.QoderWorkSkill) +} + +func writeQoderSkill(configDir string, content []byte) (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, content, 0644); err != nil { + return "", err + } + return skillPath, nil +} + +// QoderWriteHook writes a hook script to the Qoder hooks directory. +func QoderWriteHook(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 +} + +// QoderRegisterHooks registers Mnemon lifecycle hooks in Qoder settings.json. +func QoderRegisterHooks(configDir string) (string, error) { + return registerQoderHooks(configDir) +} + +// QoderWorkRegisterHooks registers Mnemon lifecycle hooks in QoderWork settings.json. +func QoderWorkRegisterHooks(configDir string) (string, error) { + return registerQoderHooks(configDir) +} + +func registerQoderHooks(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 + } + addQoderHooks(data, absHooksDir) + if err := WriteJSONFile(settingsPath, data); err != nil { + return "", err + } + return settingsPath, nil +} + +// QoderEject removes mnemon skill and hooks from the given Qoder config dir. +func QoderEject(configDir string) []error { + return ejectQoder("Qoder", configDir) +} + +// QoderWorkEject removes mnemon skill and hooks from the given QoderWork config dir. +func QoderWorkEject(configDir string) []error { + return ejectQoder("QoderWork", configDir) +} + +func ejectQoder(display, configDir string) []error { + var errs []error + + fmt.Printf("\nRemoving %s integration (%s)...\n", display, 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 { + removeQoderHooks(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 addQoderHooks(data map[string]interface{}, hooksDir string) { + removeQoderHooks(data) + hooks := ensureHooksMap(data) + + addQoderHook(hooks, "SessionStart", filepath.Join(hooksDir, "prime.sh")) + addQoderHook(hooks, "UserPromptSubmit", filepath.Join(hooksDir, "user_prompt.sh")) + addQoderHook(hooks, "Stop", filepath.Join(hooksDir, "stop.sh")) +} + +func addQoderHook(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 removeQoderHooks(data map[string]interface{}) { + hooks, ok := data["hooks"].(map[string]interface{}) + if !ok { + return + } + for _, key := range []string{"SessionStart", "UserPromptSubmit", "Stop", "PreToolUse", "PostToolUse", "PostToolUseFailure", "Notification", "PermissionRequest", "PreCompact", "SessionEnd", "SubagentStart", "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/qoder_test.go b/internal/setup/qoder_test.go new file mode 100644 index 00000000..9433d06b --- /dev/null +++ b/internal/setup/qoder_test.go @@ -0,0 +1,194 @@ +package setup + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestQoderWriteSkill(t *testing.T) { + dir := t.TempDir() + + skillPath, err := QoderWriteSkill(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) + } + if _, err := os.Stat(skillPath); err != nil { + t.Fatalf("stat skill: %v", err) + } +} + +func TestQoderWorkWriteSkill(t *testing.T) { + dir := t.TempDir() + + skillPath, err := QoderWorkWriteSkill(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), "QoderWork") { + t.Fatalf("qoderwork skill should mention QoderWork: %s", string(data)) + } +} + +func TestQoderWriteHook(t *testing.T) { + dir := t.TempDir() + + hookPath, err := QoderWriteHook(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 TestQoderRegisterHooksPreservesUnrelatedConfig(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 := QoderRegisterHooks(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("qoder hook schema should not include loop_limit: %#v", stop[0]) + } +} + +func TestQoderWorkRegisterHooksUsesSettingsJSON(t *testing.T) { + dir := t.TempDir() + + settingsPath, err := QoderWorkRegisterHooks(dir) + if err != nil { + t.Fatalf("register hooks: %v", err) + } + if settingsPath != filepath.Join(dir, "settings.json") { + t.Fatalf("settings path = %q", settingsPath) + } + data, err := ReadJSONFile(settingsPath) + if err != nil { + t.Fatalf("read settings: %v", err) + } + hooks := data["hooks"].(map[string]any) + if _, ok := hooks["SessionStart"]; !ok { + t.Fatalf("session hook should be registered: %#v", hooks) + } + if _, ok := hooks["UserPromptSubmit"]; !ok { + t.Fatalf("user prompt hook should be registered: %#v", hooks) + } + if _, ok := hooks["Stop"]; !ok { + t.Fatalf("stop hook should be registered: %#v", hooks) + } +} + +func TestQoderEjectRemovesOnlyMnemonFilesAndHooks(t *testing.T) { + dir := t.TempDir() + if _, err := QoderWriteSkill(dir); err != nil { + t.Fatalf("write skill: %v", err) + } + if _, err := QoderWriteHook(dir, "prime.sh", []byte("#!/bin/bash\n")); err != nil { + t.Fatalf("write hook: %v", err) + } + if _, err := QoderRegisterHooks(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 := QoderEject(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) + } +}