diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b9457dd..c02e19e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,5 +52,5 @@ jobs: - name: Build Windows Memory product run: go build -o mnemon.exe . - - name: Test Windows command boundary - run: go test ./cmd ./cmd/agency -count=1 + - name: Test Windows command and Memory storage boundaries + run: go test ./cmd ./cmd/agency ./cmd/memory ./internal/memory/store -count=1 diff --git a/CHANGELOG.md b/CHANGELOG.md index cca69451..0f291e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/). hook registry, but its current local Agent V2 turn path does not dispatch the user-prompt lifecycle event needed for reliable automatic recall. +## [0.2.7] - 2026-09-01 + +### Fixed + +- `--readonly recall` now resolves and encodes SQLite file URI paths correctly + on Windows and when `--data-dir` is relative to the current directory. This + fixes the misleading `SQL logic error: out of memory (1)` failure reported + in #123. +- Readonly queries retain `mode=ro` and `immutable=1`: they reject database + writes, preserve recall counters and oplog, and create no WAL/SHM sidecars. + +### Tests + +- Added real SQLite and CLI regressions for absolute, relative, and Windows + drive-relative paths, including spaces, Unicode, `#`, and `%`. +- Native Windows CI now runs the Memory command and storage tests in addition + to the product build and command-boundary tests. + ## [0.1.15] - 2026-06-18 ### Added diff --git a/cmd/memory/readonly_test.go b/cmd/memory/readonly_test.go new file mode 100644 index 00000000..5b8b1219 --- /dev/null +++ b/cmd/memory/readonly_test.go @@ -0,0 +1,97 @@ +package memory + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/mnemon-dev/mnemon/internal/memory/model" + "github.com/mnemon-dev/mnemon/internal/memory/store" +) + +func TestRecallReadOnlyPreservesSnapshot(t *testing.T) { + oldDataDir, oldStoreName, oldReadOnly := dataDir, storeName, readOnly + oldBasic, oldBrief, oldVerbose, oldLimit := recBasic, recBrief, recVerbose, recLimit + oldCategory, oldSource, oldIntent := recCategory, recSource, recIntent + t.Cleanup(func() { + dataDir, storeName, readOnly = oldDataDir, oldStoreName, oldReadOnly + recBasic, recBrief, recVerbose, recLimit = oldBasic, oldBrief, oldVerbose, oldLimit + recCategory, recSource, recIntent = oldCategory, oldSource, oldIntent + }) + root := t.TempDir() + t.Chdir(root) + t.Setenv("MNEMON_EMBED_ENDPOINT", "http://127.0.0.1:1") + storeName, readOnly = "readonly-audit", true + recBrief, recVerbose = false, false + recCategory, recSource, recIntent = "", "", "" + + for _, dir := range []string{filepath.Join(root, "absolute data # 中文 %"), "relative data # 中文 %"} { + t.Run(filepath.Base(dir), func(t *testing.T) { + dataDir = dir + db, err := store.Open(store.StoreDir(dataDir, storeName)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + insertTestInsight(t, db, "readonly-seed", "SQLite snapshot memory", "user", "2026-01-01T00:00:00Z") + if err := db.Close(); err != nil { + t.Fatal(err) + } + path := db.Path() + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name, query string + basic bool + limit int + }{ + {"metadata-sample", "", true, 6}, + {"keyword", "SQLite", true, 10}, + {"full-browse", "", true, 100000}, + {"smart", "SQLite", false, 10}, + } { + t.Run(tc.name, func(t *testing.T) { + recBasic, recLimit = tc.basic, tc.limit + var runErr error + output := captureStdout(t, func() { runErr = recallCmd.RunE(recallCmd, []string{tc.query}) }) + if runErr != nil { + t.Fatalf("readonly recall: %v", runErr) + } + var results []model.Insight + var decodeErr error + if tc.basic { + decodeErr = json.Unmarshal([]byte(output), &results) + } else { + var response struct{ Results []model.Insight } + decodeErr = json.Unmarshal([]byte(output), &response) + results = response.Results + } + if decodeErr != nil { + t.Fatalf("decode recall: %v", decodeErr) + } + if len(results) != 1 || results[0].ID != "readonly-seed" { + t.Fatalf("unexpected recall results: %s", output) + } + }) + } + + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("readonly recall changed database bytes, including access counters or oplog") + } + for _, suffix := range []string{"-wal", "-shm", "-journal"} { + if _, err := os.Stat(path + suffix); !os.IsNotExist(err) { + t.Fatalf("readonly recall created sidecar %s: %v", suffix, err) + } + } + }) + } +} diff --git a/docs/USAGE.md b/docs/USAGE.md index 5a172f57..d2a70bd0 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -14,13 +14,15 @@ These root flags configure Memory commands: | `--data-dir ` | `~/.mnemon` | Base data directory | | `--embed-model ` | `nomic-embed-text` | Embedding model (overrides `MNEMON_EMBED_MODEL`) | | `--readonly` | `false` | Open an immutable Memory database snapshot; reject write commands and create no WAL files | +| `--version` | | Print version and exit | `--readonly` is intended for a static database snapshot on a read-only mount. It rejects commands that mutate Memory data and suppresses incidental recall counters/oplog writes. Do not use it to follow a database another process is actively changing; immutable snapshots deliberately ignore concurrent WAL -updates. -| `--version` | | Print version and exit | +updates. Pass a filesystem path to `--data-dir`, including Windows drive-letter +paths or paths relative to the current directory. Mnemon resolves and encodes +the read-only SQLite file URI internally; do not prepend `file:` yourself. --- diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 0653a389..eb3e1f76 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -14,11 +14,13 @@ | `--data-dir ` | `~/.mnemon` | 基础数据目录 | | `--embed-model ` | `nomic-embed-text` | 嵌入模型(覆盖 `MNEMON_EMBED_MODEL`) | | `--readonly` | `false` | 打开不可变的 Memory 数据库快照;拒绝写命令且不创建 WAL 文件 | +| `--version` | | 打印版本并退出 | `--readonly` 适用于只读挂载上的静态数据库快照。它会拒绝修改 Memory 数据的命令,并禁用 recall 计数器和 oplog 等附带写入。请勿用它跟随由另一个 进程持续修改的数据库;不可变快照会有意忽略并发 WAL 更新。 -| `--version` | | 打印版本并退出 | +`--data-dir` 接受文件系统路径,包括 Windows 盘符路径和相对于当前目录的路径。 +Mnemon 会在内部解析并编码只读 SQLite 文件 URI,无需手动添加 `file:` 前缀。 --- diff --git a/internal/memory/store/db.go b/internal/memory/store/db.go index 78abb69c..a592bd87 100644 --- a/internal/memory/store/db.go +++ b/internal/memory/store/db.go @@ -213,7 +213,18 @@ func OpenReadOnly(dataDir string) (*DB, error) { // mode=ro is a SQLite URI parameter, not a generic filename query // parameter. Without the file: URI scheme modernc/sqlite treats this as a // normal read-write open and silently ignores the intended protection. - dsn := &url.URL{Scheme: "file", Path: filepath.ToSlash(dbPath)} + absolutePath, err := filepath.Abs(dbPath) + if err != nil { + return nil, fmt.Errorf("resolve readonly database path: %w", err) + } + uriPath := filepath.ToSlash(absolutePath) + // A Windows drive belongs in the URI path (/C:/...), not its authority. + // Resolving relative paths first also prevents their first directory from + // becoming a URI authority on other platforms. + if !strings.HasPrefix(uriPath, "/") { + uriPath = "/" + uriPath + } + dsn := &url.URL{Scheme: "file", Path: uriPath} query := dsn.Query() query.Set("mode", "ro") query.Set("immutable", "1") diff --git a/internal/memory/store/readonly_path_test.go b/internal/memory/store/readonly_path_test.go new file mode 100644 index 00000000..983d002d --- /dev/null +++ b/internal/memory/store/readonly_path_test.go @@ -0,0 +1,82 @@ +package store + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestOpenReadOnlyPathForms(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + cases := []struct{ name, dir string }{ + {"absolute", filepath.Join(root, "absolute store # 中文 %")}, + {"relative", "relative store # 中文 %"}, + } + if runtime.GOOS == "windows" { + cases = append(cases, struct{ name, dir string }{ + "drive-relative", filepath.VolumeName(root) + "drive-relative store # 中文 %", + }) + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + absoluteDir, err := filepath.Abs(tc.dir) + if err != nil { + t.Fatal(err) + } + db, err := Open(absoluteDir) + if err != nil { + t.Fatalf("create store: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.InsertInsight(makeInsight("readonly-seed", "snapshot memory", 2)); err != nil { + t.Fatalf("insert seed: %v", err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + path := filepath.Join(absoluteDir, "mnemon.db") + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + ro, err := OpenReadOnly(tc.dir) + if err != nil { + t.Fatalf("open readonly: %v", err) + } + t.Cleanup(func() { _ = ro.Close() }) + insights, err := ro.QueryInsights(QueryFilter{Keyword: "snapshot", Limit: 10}) + if err != nil { + t.Fatalf("query readonly store: %v", err) + } + if len(insights) != 1 || insights[0].ID != "readonly-seed" { + t.Fatalf("unexpected readonly results: %+v", insights) + } + if err := ro.IncrementAccessCount("readonly-seed"); err != nil { + t.Fatal(err) + } + ro.LogOp("recall:basic", "", "readonly probe") + if _, err := ro.Conn().Exec("UPDATE insights SET access_count = 7"); err == nil { + t.Fatal("readonly connection accepted a database mutation") + } + for _, suffix := range []string{"-wal", "-shm", "-journal"} { + if _, err := os.Stat(path + suffix); !os.IsNotExist(err) { + t.Fatalf("readonly access created sidecar %s: %v", suffix, err) + } + } + if err := ro.Close(); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("readonly access changed database bytes") + } + }) + } +} diff --git a/scripts/e2e_test.sh b/scripts/e2e_test.sh index 57080f2c..a087c2aa 100755 --- a/scripts/e2e_test.sh +++ b/scripts/e2e_test.sh @@ -228,6 +228,25 @@ assert_jq "importance is 4" "$OUT" '.importance' '4' assert_contains "tags include tool" "$OUT" '"tool"' assert_contains "entities has Qdrant" "$OUT" '"Qdrant"' +step "readonly recall — absolute and relative data paths preserve the database" +cp "$TESTDIR/data/default/mnemon.db" "$TESTDATA/readonly-before.db" +OUT=$("$M" --data-dir "$TESTDIR" --store default --readonly recall "" --basic --limit 100000) +assert_jq "readonly absolute path finds the seed" "$OUT" '.[0].id' "$ID1" +OUT=$(cd "$TESTDATA" && "$M" --data-dir "m1" --store default --readonly recall "Qdrant" --basic --limit 6) +assert_jq "readonly relative path finds the seed" "$OUT" '.[0].id' "$ID1" +if cmp -s "$TESTDATA/readonly-before.db" "$TESTDIR/data/default/mnemon.db"; then + pass "readonly recall preserves database bytes" "(including counters and oplog)" +else + fail "readonly recall preserves database bytes" "(database changed)" +fi +for suffix in -wal -shm -journal; do + if [ -e "$TESTDIR/data/default/mnemon.db$suffix" ]; then + fail "readonly recall creates no $suffix sidecar" "(sidecar exists)" + else + pass "readonly recall creates no $suffix sidecar" "(absent)" + fi +done + step "recall — keyword search (compact default)" OUT=$($M --data-dir "$TESTDIR" recall "Qdrant") show_json "$OUT" 10