Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions cmd/memory/readonly_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
6 changes: 4 additions & 2 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ These root flags configure Memory commands:
| `--data-dir <path>` | `~/.mnemon` | Base data directory |
| `--embed-model <name>` | `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.

---

Expand Down
4 changes: 3 additions & 1 deletion docs/zh/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
| `--data-dir <path>` | `~/.mnemon` | 基础数据目录 |
| `--embed-model <name>` | `nomic-embed-text` | 嵌入模型(覆盖 `MNEMON_EMBED_MODEL`) |
| `--readonly` | `false` | 打开不可变的 Memory 数据库快照;拒绝写命令且不创建 WAL 文件 |
| `--version` | | 打印版本并退出 |

`--readonly` 适用于只读挂载上的静态数据库快照。它会拒绝修改 Memory
数据的命令,并禁用 recall 计数器和 oplog 等附带写入。请勿用它跟随由另一个
进程持续修改的数据库;不可变快照会有意忽略并发 WAL 更新。
| `--version` | | 打印版本并退出 |
`--data-dir` 接受文件系统路径,包括 Windows 盘符路径和相对于当前目录的路径。
Mnemon 会在内部解析并编码只读 SQLite 文件 URI,无需手动添加 `file:` 前缀。

---

Expand Down
13 changes: 12 additions & 1 deletion internal/memory/store/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
82 changes: 82 additions & 0 deletions internal/memory/store/readonly_path_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
}
19 changes: 19 additions & 0 deletions scripts/e2e_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading