diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 2567e63b7..41a422c15 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -2,6 +2,14 @@ name: Manual Integration on: workflow_dispatch: + inputs: + suite: + description: Integration suite to run + type: choice + default: all + options: + - all + - zcode-windows permissions: contents: read @@ -12,6 +20,7 @@ concurrency: jobs: verify: + if: inputs.suite != 'zcode-windows' runs-on: ubuntu-latest timeout-minutes: 60 @@ -29,3 +38,28 @@ jobs: - name: Run integration suite run: make test-integration + + zcode-windows: + if: inputs.suite == 'all' || inputs.suite == 'zcode-windows' + runs-on: windows-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Build Windows Memory product + run: go build -o mnemon.exe . + + - name: Test installed ZCode hooks in Windows PowerShell 5.1 + shell: powershell + run: | + $env:MNEMON_TEST_BIN = (Resolve-Path ./mnemon.exe).Path + go test ./test/memory/zcode -v -count=1 diff --git a/README.md b/README.md index dd292d51c..3bf58b672 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,14 @@ sessions, add recall guidance before model calls, and prompt for durable-memory writeback at stop. Without `--global`, setup installs only the project skill; ZCode currently ignores project-level hook configuration. +The Stop hook honors `stop_hook_active` to avoid repeated reminders. Its +completion fallback recognizes `mnemon`, `durable memory`, `持久记忆`, and +`写入记忆`; the reminder asks for a `[mnemon]` marker so evaluations in any +language can finish without another reminder. Windows hooks decode stdin as +UTF-8, and the PowerShell script containing Chinese tokens includes a UTF-8 BOM +for Windows PowerShell 5.1. Re-run setup and start a new ZCode session to update +previously installed hooks. + ### [MiniMax Code](https://github.com/MiniMax-AI/minimax-code) ```bash diff --git a/docs/zh/README.md b/docs/zh/README.md index ff26ac1e6..8e7defedb 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -136,6 +136,12 @@ ZCode 会将 Mnemon skill 安装到 `~/.zcode/skills/`,并在 模型调用前会收到 recall 指引,停止时会评估 durable-memory 回写。不加 `--global` 时只安装项目 skill,因为 ZCode 当前不会执行项目级 hooks 配置。 +Stop hook 优先检查 `stop_hook_active`,避免重复提醒。完成标记兼容 `mnemon`、 +`durable memory`、`持久记忆` 和 `写入记忆`;提醒会要求在评估结果中包含 +`[mnemon]`,以支持任意语言。Windows hooks 显式以 UTF-8 解码 stdin,含中文 +标记的 PowerShell 脚本带 UTF-8 BOM,以兼容 Windows PowerShell 5.1。 +重新运行 setup 并开启新的 ZCode 会话,即可更新已安装的 hooks。 + ### [MiniMax Code](https://github.com/MiniMax-AI/minimax-code) ```bash diff --git a/internal/memory/setup/assets/zcode/prime.ps1 b/internal/memory/setup/assets/zcode/prime.ps1 index e34d00f09..7f26b7bec 100644 --- a/internal/memory/setup/assets/zcode/prime.ps1 +++ b/internal/memory/setup/assets/zcode/prime.ps1 @@ -1,5 +1,7 @@ # mnemon ZCode SessionStart hook for Windows PowerShell. +# ZCode sends UTF-8 JSON regardless of the Windows console code page. +[Console]::InputEncoding = [Text.Encoding]::UTF8 $null = [Console]::In.ReadToEnd() function Resolve-Mnemon { diff --git a/internal/memory/setup/assets/zcode/stop.ps1 b/internal/memory/setup/assets/zcode/stop.ps1 index 9219b6082..5533e4a4f 100644 --- a/internal/memory/setup/assets/zcode/stop.ps1 +++ b/internal/memory/setup/assets/zcode/stop.ps1 @@ -1,5 +1,7 @@ -# mnemon ZCode Stop hook for Windows PowerShell. +# mnemon ZCode Stop hook for Windows PowerShell. +# ZCode sends UTF-8 JSON regardless of the Windows console code page. +[Console]::InputEncoding = [Text.Encoding]::UTF8 $raw = [Console]::In.ReadToEnd() try { $payload = $raw | ConvertFrom-Json @@ -15,11 +17,12 @@ $lastMessage = "" if ($null -ne $payload -and $null -ne $payload.last_assistant_message) { $lastMessage = ([string]$payload.last_assistant_message).ToLowerInvariant() } -if ($lastMessage.Contains("mnemon") -or $lastMessage.Contains("durable memory")) { +if ($lastMessage.Contains("mnemon") -or $lastMessage.Contains("durable memory") -or + $lastMessage.Contains("持久记忆") -or $lastMessage.Contains("写入记忆")) { exit 0 } [ordered]@{ decision = "block" - reason = "[mnemon] Briefly evaluate whether this exchange warrants durable memory. If yes, use the mnemon skill/CLI to remember only durable, non-secret facts; otherwise say no durable memory is needed." + reason = "[mnemon] Briefly evaluate whether this exchange warrants durable memory. If yes, use the mnemon skill/CLI to remember only durable, non-secret facts; otherwise say no durable memory is needed. Include [mnemon] in your evaluation, in any language." } | ConvertTo-Json -Compress diff --git a/internal/memory/setup/assets/zcode/stop.sh b/internal/memory/setup/assets/zcode/stop.sh index 8786ff3a8..4a3eea852 100644 --- a/internal/memory/setup/assets/zcode/stop.sh +++ b/internal/memory/setup/assets/zcode/stop.sh @@ -14,7 +14,7 @@ if payload.get("stop_hook_active"): sys.exit(0) last_message = (payload.get("last_assistant_message") or "").lower() -if "mnemon" in last_message or "durable memory" in last_message: +if any(token in last_message for token in ("mnemon", "durable memory", "持久记忆", "写入记忆")): sys.exit(0) print(json.dumps({ @@ -22,7 +22,8 @@ print(json.dumps({ "reason": ( "[mnemon] Briefly evaluate whether this exchange warrants durable memory. " "If yes, use the mnemon skill/CLI to remember only durable, non-secret facts; " - "otherwise say no durable memory is needed." + "otherwise say no durable memory is needed. " + "Include [mnemon] in your evaluation, in any language." ), })) ' diff --git a/internal/memory/setup/assets/zcode/user_prompt.ps1 b/internal/memory/setup/assets/zcode/user_prompt.ps1 index 56bbf8797..026f5b47d 100644 --- a/internal/memory/setup/assets/zcode/user_prompt.ps1 +++ b/internal/memory/setup/assets/zcode/user_prompt.ps1 @@ -1,5 +1,7 @@ # mnemon ZCode UserPromptSubmit hook for Windows PowerShell. +# ZCode sends UTF-8 JSON regardless of the Windows console code page. +[Console]::InputEncoding = [Text.Encoding]::UTF8 $null = [Console]::In.ReadToEnd() [ordered]@{ diff --git a/internal/memory/setup/zcode_test.go b/internal/memory/setup/zcode_test.go index 0d230f2f9..437d864a4 100644 --- a/internal/memory/setup/zcode_test.go +++ b/internal/memory/setup/zcode_test.go @@ -1,12 +1,52 @@ package setup import ( + "bytes" "os" "path/filepath" "strings" "testing" + "unicode/utf8" + + "github.com/mnemon-dev/mnemon/internal/memory/setup/assets" ) +func TestZCodePowerShellHookEncoding(t *testing.T) { + bom := []byte{0xef, 0xbb, 0xbf} + for _, hook := range []struct { + name string + body []byte + }{ + {"prime.ps1", assets.ZCodePrimeHookPowerShell}, + {"user_prompt.ps1", assets.ZCodeUserPromptHookPowerShell}, + {"stop.ps1", assets.ZCodeStopHookPowerShell}, + } { + t.Run(hook.name, func(t *testing.T) { + if !utf8.Valid(hook.body) { + t.Fatal("PowerShell asset is not valid UTF-8") + } + // Windows PowerShell 5.1 reads BOM-less script source as ANSI. + // Any asset with non-ASCII literals must carry its own UTF-8 BOM. + for _, b := range bytes.TrimPrefix(hook.body, bom) { + if b >= utf8.RuneSelf && !bytes.HasPrefix(hook.body, bom) { + t.Fatal("non-ASCII PowerShell source requires a UTF-8 BOM") + } + } + path, err := ZCodeWriteHook(t.TempDir(), hook.name, hook.body) + if err != nil { + t.Fatal(err) + } + installed, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(installed, hook.body) { + t.Fatal("setup changed the embedded PowerShell bytes") + } + }) + } +} + func TestZCodeRegisterHooksPreservesUnrelatedConfig(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "cli", "config.json") diff --git a/test/memory/zcode/README.md b/test/memory/zcode/README.md new file mode 100644 index 000000000..cdbfae81e --- /dev/null +++ b/test/memory/zcode/README.md @@ -0,0 +1,32 @@ +# ZCode hook integration tests + +These opt-in process tests run with `go test ./test/memory/zcode -v -count=1`. +They are outside `make test`; `make test-integration` includes the package. +On Windows, `powershell.exe` 5.1 is required and the suite installs hooks through +the built product's `setup --target zcode --global --yes` command in a temporary +home containing spaces and Chinese characters. Set `MNEMON_TEST_BIN` to reuse +an existing product binary; otherwise the test builds one. + +The **Manual Integration** workflow's `zcode-windows` suite runs only this +boundary on a Windows runner. The default `all` selection also runs the existing +Linux integration suite. The process writes UTF-8 JSON bytes to stdin while a +wrapper selects console code page 936 (GBK) or 65001 (UTF-8). The unchanged +`stop-before-142.ps1` fixture comes from commit +`4d312c4ebb42bf9f85442120fad4b9e85d4779e2`; its failures establish the regression +control in the same host. The fixed scripts must preserve installed bytes, +including the Stop script's UTF-8 BOM, honor the host's active guard, recognize +the documented completion tokens, and produce valid hook JSON. + +Empty and malformed payloads retain the existing blocking reminder behavior. +These tests do not claim general natural-language evaluation detection. + +On other platforms, the shell Stop cases run normally. Set +`MNEMON_TEST_POWERSHELL` to an explicit `pwsh` path to also probe the PowerShell +assets using the production hook writer. PowerShell 7 evidence does not replace +Windows PowerShell 5.1 validation of source encoding and native setup. + +Encoding references: + +- [Microsoft's PowerShell character encoding guide](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_character_encoding?view=powershell-5.1) +- [Console.InputEncoding](https://learn.microsoft.com/en-us/dotnet/api/system.console.inputencoding?view=netframework-4.8.1) +- [ZCode hook protocol](https://zcode.z.ai/en/docs/hooks) diff --git a/test/memory/zcode/hooks_test.go b/test/memory/zcode/hooks_test.go new file mode 100644 index 000000000..b26eef4a4 --- /dev/null +++ b/test/memory/zcode/hooks_test.go @@ -0,0 +1,258 @@ +package zcode_test + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/mnemon-dev/mnemon/internal/memory/setup" + "github.com/mnemon-dev/mnemon/internal/memory/setup/assets" +) + +var stopCases = []struct { + name, input string + block bool +}{ + {"ordinary", `{"stop_hook_active":false,"last_assistant_message":"Done."}`, true}, + {"ordinary Chinese", `{"last_assistant_message":"中文任务完成。"}`, true}, + {"active", `{"stop_hook_active":true}`, false}, + {"active Chinese", `{"stop_hook_active":true,"last_assistant_message":"中"}`, false}, + {"active escaped Unicode", `{"stop_hook_active":true,"last_assistant_message":"中文\"引号\",路径 C:\\工作 😀"}`, false}, + {"English evaluation", `{"last_assistant_message":"No Durable Memory is needed."}`, false}, + {"language neutral marker", `{"last_assistant_message":"[Mnemon] 本轮无需保存。"}`, false}, + {"Chinese no memory", `{"last_assistant_message":"无需写入持久记忆"}`, false}, + {"Chinese memory saved", `{"last_assistant_message":"已写入记忆"}`, false}, + // Preserve the existing reminder behavior for missing or malformed payloads. + {"empty", "", true}, + {"empty object", `{}`, true}, + {"malformed", `{"stop_hook_active":true,`, true}, +} + +func TestStopShell(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows installs PowerShell hooks") + } + bash, err := exec.LookPath("bash") + if err != nil { + t.Fatal(err) + } + script := writeFile(t, t.TempDir(), "stop.sh", assets.ZCodeStopHook) + for _, tc := range stopCases { + t.Run(tc.name, func(t *testing.T) { + out := run(t, bash, []string{script}, tc.input, os.Environ()) + assertStop(t, out, tc.block) + }) + } +} + +func TestPowerShellHooks(t *testing.T) { + powershell := os.Getenv("MNEMON_TEST_POWERSHELL") + if runtime.GOOS == "windows" { + // Never silently substitute PowerShell 7 for the affected Windows host. + var err error + powershell, err = exec.LookPath("powershell.exe") + if err != nil { + t.Fatalf("Windows PowerShell 5.1 is required: %v", err) + } + } else if powershell == "" { + t.Skip("set MNEMON_TEST_POWERSHELL for a portable PowerShell probe; Windows runs 5.1") + } + + home := filepath.Join(t.TempDir(), "ZCode 测试 home") + if err := os.MkdirAll(home, 0o755); err != nil { + t.Fatal(err) + } + bin := productBinary(t) + env := isolatedEnv(home, bin) + version := run(t, powershell, []string{"-NoProfile", "-NonInteractive", "-Command", + `[ordered]@{ major = $PSVersionTable.PSVersion.Major; minor = $PSVersionTable.PSVersion.Minor; ansi = [Text.Encoding]::Default.CodePage } | ConvertTo-Json -Compress`}, "", env) + var host struct{ Major, Minor, ANSI int } + if err := json.Unmarshal(version, &host); err != nil { + t.Fatal(err) + } + if runtime.GOOS == "windows" && (host.Major != 5 || host.Minor != 1) { + t.Fatalf("expected Windows PowerShell 5.1, got %s", version) + } + t.Logf("PowerShell host: %s", bytes.TrimSpace(version)) + run(t, bin, []string{"setup", "--target", "zcode", "--global", "--yes"}, "", env) + hooks := installedHooks(t, home) + writeFile(t, filepath.Join(home, ".mnemon", "prompt"), "guide.md", []byte("ZCode test guide.")) + wrapper := writeFile(t, home, "invoke.ps1", []byte(` +[Console]::InputEncoding = [Text.Encoding]::GetEncoding([int]$env:MNEMON_TEST_CODEPAGE) +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +& $env:MNEMON_TEST_HOOK +`)) + legacy, err := filepath.Abs("../../../testdata/memory/zcode/stop-before-142.ps1") + if err != nil { + t.Fatal(err) + } + for _, codepage := range []int{65001, 936} { + t.Run("codepage="+strconv.Itoa(codepage), func(t *testing.T) { + invoke := func(t *testing.T, script, input string) []byte { + t.Helper() + hookEnv := append(append([]string{}, env...), "MNEMON_TEST_CODEPAGE="+strconv.Itoa(codepage), "MNEMON_TEST_HOOK="+script) + return run(t, powershell, []string{"-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", wrapper}, input, hookEnv) + } + // Execute the old asset too: the CP936 case loses the active guard, + // and the Chinese evaluation is missed even with correct UTF-8 input. + t.Run("legacy active guard control", func(t *testing.T) { + assertStop(t, invoke(t, legacy, `{"stop_hook_active":true,"last_assistant_message":"中"}`), codepage == 936) + }) + t.Run("legacy Chinese evaluation control", func(t *testing.T) { + assertStop(t, invoke(t, legacy, `{"last_assistant_message":"无需写入持久记忆"}`), true) + }) + t.Run("null PowerShell payload", func(t *testing.T) { + assertStop(t, invoke(t, hooks["stop.ps1"], `null`), true) + }) + for _, tc := range stopCases { + t.Run(tc.name, func(t *testing.T) { + out := invoke(t, hooks["stop.ps1"], tc.input) + assertStop(t, out, tc.block) + if tc.block && !bytes.Contains(out, []byte("Include [mnemon]")) { + t.Fatal("reminder must explain the completion marker") + } + }) + } + for _, tc := range []struct{ script, event, context string }{ + {"user_prompt.ps1", "UserPromptSubmit", "[mnemon] Evaluate: recall needed? After responding, evaluate: remember needed?"}, + {"prime.ps1", "SessionStart", "[mnemon] Memory active (0 insights, 0 edges).\nZCode test guide."}, + } { + t.Run(tc.event, func(t *testing.T) { + out := invoke(t, hooks[tc.script], `{"prompt":"中文输入 😀"}`) + var result struct { + HookSpecificOutput struct{ HookEventName, AdditionalContext string } + } + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("decode hook output %q: %v", out, err) + } + if result.HookSpecificOutput.HookEventName != tc.event || result.HookSpecificOutput.AdditionalContext != tc.context { + t.Fatalf("unexpected hook output: %s", out) + } + }) + } + }) + } +} + +func installedHooks(t *testing.T, home string) map[string]string { + t.Helper() + config := filepath.Join(home, ".zcode") + hooks := map[string]string{} + for _, hook := range []struct { + name string + body []byte + }{ + {"stop.ps1", assets.ZCodeStopHookPowerShell}, + {"prime.ps1", assets.ZCodePrimeHookPowerShell}, + {"user_prompt.ps1", assets.ZCodeUserPromptHookPowerShell}, + } { + // A portable probe uses the production writer because setup on Unix + // selects shell scripts. Windows checks the actual setup CLI output. + if runtime.GOOS != "windows" { + if _, err := setup.ZCodeWriteHook(config, hook.name, hook.body); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(config, "hooks", "mnemon", hook.name) + installed, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(installed, hook.body) { + t.Fatalf("installed %s differs from its embedded asset", hook.name) + } + if hook.name == "stop.ps1" && !bytes.HasPrefix(installed, []byte{0xef, 0xbb, 0xbf}) { + t.Fatal("installed Stop script must have a UTF-8 BOM for PowerShell 5.1") + } + hooks[hook.name] = path + } + return hooks +} + +func productBinary(t *testing.T) string { + t.Helper() + if bin := os.Getenv("MNEMON_TEST_BIN"); bin != "" { + abs, err := filepath.Abs(bin) + if err != nil { + t.Fatal(err) + } + return abs + } + bin := filepath.Join(t.TempDir(), "mnemon") + if runtime.GOOS == "windows" { + bin += ".exe" + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "build", "-o", bin, "../../..") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build product: %v\n%s", err, out) + } + return bin +} + +func isolatedEnv(home, bin string) []string { + var env []string + for _, value := range os.Environ() { + key := strings.ToUpper(strings.SplitN(value, "=", 2)[0]) + if strings.HasPrefix(key, "MNEMON_") || key == "HOME" || key == "USERPROFILE" || key == "XDG_CONFIG_HOME" { + continue + } + env = append(env, value) + } + return append(env, "HOME="+home, "USERPROFILE="+home, "XDG_CONFIG_HOME="+filepath.Join(home, ".config"), + "MNEMON_DATA_DIR="+filepath.Join(home, ".mnemon"), "MNEMON_BIN="+bin, "MNEMON_EMBED_ENDPOINT=http://127.0.0.1:1") +} + +func writeFile(t *testing.T, dir, name string, data []byte) string { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func run(t *testing.T, executable string, args []string, input string, env []string) []byte { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, executable, args...) + cmd.Stdin = strings.NewReader(input) + cmd.Env = env + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil || stderr.Len() != 0 { + t.Fatalf("process failed: %v\nstdout: %s\nstderr: %s", err, out, &stderr) + } + return out +} + +func assertStop(t *testing.T, out []byte, block bool) { + t.Helper() + if !block { + if len(bytes.TrimSpace(out)) != 0 { + t.Fatalf("expected silent exit, got %s", out) + } + return + } + var result struct{ Decision, Reason string } + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("decode stop output %q: %v", out, err) + } + if result.Decision != "block" || result.Reason == "" { + t.Fatalf("expected one blocking reminder, got %s", out) + } +} diff --git a/testdata/memory/zcode/stop-before-142.ps1 b/testdata/memory/zcode/stop-before-142.ps1 new file mode 100644 index 000000000..9219b6082 --- /dev/null +++ b/testdata/memory/zcode/stop-before-142.ps1 @@ -0,0 +1,25 @@ +# mnemon ZCode Stop hook for Windows PowerShell. + +$raw = [Console]::In.ReadToEnd() +try { + $payload = $raw | ConvertFrom-Json +} catch { + $payload = $null +} + +if ($null -ne $payload -and $payload.stop_hook_active -eq $true) { + exit 0 +} + +$lastMessage = "" +if ($null -ne $payload -and $null -ne $payload.last_assistant_message) { + $lastMessage = ([string]$payload.last_assistant_message).ToLowerInvariant() +} +if ($lastMessage.Contains("mnemon") -or $lastMessage.Contains("durable memory")) { + exit 0 +} + +[ordered]@{ + decision = "block" + reason = "[mnemon] Briefly evaluate whether this exchange warrants durable memory. If yes, use the mnemon skill/CLI to remember only durable, non-secret facts; otherwise say no durable memory is needed." +} | ConvertTo-Json -Compress