diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01e8b36..c281934 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,10 +22,10 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Format - run: cargo fmt --all -- --check + run: make fmt-check - name: Cargo check - run: cargo check --workspace + run: make check - - name: Cargo test - run: cargo test --workspace + - name: Validate test suite + run: make test-ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6ab8fc..b2a899c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,13 +47,13 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Format - run: cargo fmt --all -- --check + run: make fmt-check - name: Cargo check - run: cargo check --workspace + run: make check - - name: Cargo test - run: cargo test --workspace + - name: Validate test suite + run: make test-ci build: name: Build ${{ matrix.target }} @@ -93,11 +93,12 @@ jobs: set -eu STAGE_DIR="dist/memory-bank" BIN_DIR="${STAGE_DIR}/bin" + CONFIG_DIR="${STAGE_DIR}/config" OPENCODE_DIR="${STAGE_DIR}/integrations/opencode" OPENCLAW_DIR="${STAGE_DIR}/integrations/openclaw" rm -rf dist - mkdir -p "${BIN_DIR}" "${OPENCODE_DIR}" "${OPENCLAW_DIR}" + mkdir -p "${BIN_DIR}" "${CONFIG_DIR}" "${OPENCODE_DIR}" "${OPENCLAW_DIR}" cp "target/${TARGET}/release/mb" "${BIN_DIR}/mb" cp "target/${TARGET}/release/memory-bank-server" "${BIN_DIR}/memory-bank-server" @@ -111,6 +112,7 @@ jobs: cp ".opencode/plugins/memory-bank.js" "${OPENCODE_DIR}/memory-bank.js" cp -R ".openclaw/extensions/memory-bank" "${OPENCLAW_DIR}/memory-bank" + cp "config/setup-model-catalog.json" "${CONFIG_DIR}/setup-model-catalog.json" tar -C "${STAGE_DIR}" -czf "memory-bank-${TARGET}.tar.gz" . diff --git a/.openclaw/extensions/memory-bank/index.js b/.openclaw/extensions/memory-bank/index.js index 0e163be..6175429 100644 --- a/.openclaw/extensions/memory-bank/index.js +++ b/.openclaw/extensions/memory-bank/index.js @@ -5,6 +5,7 @@ const { spawnSync } = require("node:child_process"); const DEFAULT_AGENT = "openclaw"; const APP_ROOT_DIR_NAME = ".memory_bank"; +const APP_SETTINGS_FILE_NAME = "settings.toml"; const DEFAULT_SERVER_URL = "http://127.0.0.1:3737"; const DEDUPE_CACHE_LIMIT = 2048; const DUPLICATE_WINDOW_MS = 5_000; @@ -315,18 +316,86 @@ function loadAppSettings(platform) { return null; } - const settingsPath = path.join(appRoot, "settings.json"); + const settingsPath = path.join(appRoot, APP_SETTINGS_FILE_NAME); if (!platform.fileExists(settingsPath)) { return null; } try { - return JSON.parse(platform.readFile(settingsPath)); + return parseAppSettingsToml(platform.readFile(settingsPath)); } catch { return null; } } +function parseAppSettingsToml(contents) { + let currentSection = null; + let port = null; + + for (const rawLine of String(contents).replace(/^\uFEFF/, "").split(/\r?\n/u)) { + const line = stripTomlLineComment(rawLine).trim(); + if (!line) { + continue; + } + + const sectionMatch = line.match(/^\[(.+)\]$/u); + if (sectionMatch) { + currentSection = sectionMatch[1].trim(); + continue; + } + + if (currentSection !== "service") { + continue; + } + + const portMatch = line.match(/^port\s*=\s*(\d+)\s*$/u); + if (!portMatch) { + continue; + } + + const parsed = Number.parseInt(portMatch[1], 10); + if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535) { + port = parsed; + } + } + + return port === null ? null : { service: { port } }; +} + +function stripTomlLineComment(line) { + let inDoubleQuote = false; + let escaped = false; + let result = ""; + + for (const char of line) { + if (inDoubleQuote) { + result += char; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inDoubleQuote = false; + } + continue; + } + + if (char === '"') { + inDoubleQuote = true; + result += char; + continue; + } + + if (char === "#") { + break; + } + + result += char; + } + + return result; +} + function resolveServerUrlFromSettings(appSettings) { const port = appSettings?.service?.port; if (typeof port !== "number" || !Number.isFinite(port)) { diff --git a/.opencode/plugins/memory-bank.js b/.opencode/plugins/memory-bank.js index 612cfdf..b35d011 100644 --- a/.opencode/plugins/memory-bank.js +++ b/.opencode/plugins/memory-bank.js @@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process"; const LOG_SERVICE = "memory-bank-opencode"; const DEFAULT_AGENT = "opencode"; const APP_ROOT_DIR_NAME = ".memory_bank"; +const APP_SETTINGS_FILE_NAME = "settings.toml"; const DEFAULT_SERVER_URL = "http://127.0.0.1:3737"; const DEDUPE_CACHE_LIMIT = 2048; const IMMEDIATE_ASSISTANT_RETRY_ATTEMPTS = 4; @@ -888,19 +889,87 @@ function loadAppSettings(platform) { return null; } - const settingsPath = path.join(appRoot, "settings.json"); + const settingsPath = path.join(appRoot, APP_SETTINGS_FILE_NAME); if (!platform.fileExists(settingsPath)) { return null; } try { const contents = platform.readFile(settingsPath); - return JSON.parse(contents); + return parseAppSettingsToml(contents); } catch { return null; } } +function parseAppSettingsToml(contents) { + let currentSection = null; + let port = null; + + for (const rawLine of String(contents).replace(/^\uFEFF/, "").split(/\r?\n/u)) { + const line = stripTomlLineComment(rawLine).trim(); + if (!line) { + continue; + } + + const sectionMatch = line.match(/^\[(.+)\]$/u); + if (sectionMatch) { + currentSection = sectionMatch[1].trim(); + continue; + } + + if (currentSection !== "service") { + continue; + } + + const portMatch = line.match(/^port\s*=\s*(\d+)\s*$/u); + if (!portMatch) { + continue; + } + + const parsed = Number.parseInt(portMatch[1], 10); + if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535) { + port = parsed; + } + } + + return port === null ? null : { service: { port } }; +} + +function stripTomlLineComment(line) { + let inDoubleQuote = false; + let escaped = false; + let result = ""; + + for (const char of line) { + if (inDoubleQuote) { + result += char; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inDoubleQuote = false; + } + continue; + } + + if (char === '"') { + inDoubleQuote = true; + result += char; + continue; + } + + if (char === "#") { + break; + } + + result += char; + } + + return result; +} + function resolveServerUrlFromSettings(appSettings) { const port = appSettings?.service?.port; if (typeof port !== "number" || !Number.isFinite(port)) { diff --git a/Cargo.lock b/Cargo.lock index 0388289..93ca2fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -359,6 +359,12 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -604,7 +610,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.59.0", ] @@ -731,6 +737,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -867,19 +898,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dialoguer" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" -dependencies = [ - "console", - "shell-words", - "tempfile", - "thiserror 1.0.69", - "zeroize", -] - [[package]] name = "digest" version = "0.10.7" @@ -966,6 +984,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "equator" version = "0.4.2" @@ -1288,6 +1312,24 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1817,10 +1859,27 @@ dependencies = [ "console", "number_prefix", "portable-atomic", - "unicode-width", + "unicode-width 0.2.2", "web-time", ] +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.11.0", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -1911,6 +1970,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonc-parser" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb0774546269185d38da823d8583e94448ba0e158e3f50759d9c79aba946ca5" +dependencies = [ + "serde_json", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1960,7 +2028,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", "redox_syscall 0.7.2", ] @@ -2100,6 +2168,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.18", + "toml", ] [[package]] @@ -2108,13 +2177,17 @@ version = "0.1.0" dependencies = [ "chrono", "clap", - "dialoguer", + "inquire", + "jsonc-parser", "memory-bank-app", "serde", "serde_json", + "shlex", "tempfile", "thiserror 2.0.18", "ureq 2.12.1", + "wait-timeout", + "which", ] [[package]] @@ -2229,6 +2302,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.1.1" @@ -2319,6 +2404,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "nom" version = "7.1.3" @@ -2474,7 +2568,7 @@ version = "6.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", "once_cell", "onig_sys", @@ -2496,7 +2590,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "foreign-types", "libc", @@ -2700,7 +2794,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crc32fast", "fdeflate", "flate2", @@ -3042,7 +3136,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -3051,7 +3145,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d94dd2f7cd932d4dc02cc8b2b50dfd38bd079a4e5d79198b99743d7fcf9a4b4" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -3330,7 +3424,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -3493,7 +3587,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3581,6 +3675,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3624,18 +3727,33 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3842,7 +3960,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags", + "bitflags 2.11.0", "byteorder", "bytes", "crc", @@ -3884,7 +4002,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags", + "bitflags 2.11.0", "byteorder", "crc", "dotenvy", @@ -4022,7 +4140,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4179,7 +4297,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.1", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -4254,6 +4372,45 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "toml_writer" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" + [[package]] name = "tower" version = "0.5.3" @@ -4276,7 +4433,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -4434,6 +4591,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -4578,6 +4741,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -4740,7 +4912,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -4799,6 +4971,18 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "whoami" version = "1.6.1" @@ -5207,6 +5391,24 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -5265,7 +5467,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 5447f43..5f9b9c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,9 +21,10 @@ blake3 = "1.8.2" chrono = { version = "0.4.44", features = ["serde"] } clap = { version = "4.5.60", features = ["derive", "env"] } dirs = "6" -dialoguer = "0.11.0" fastembed = "5.11.0" hf-hub = "0.4" +inquire = "0.7.5" +jsonc-parser = { version = "0.29.0", features = ["serde"] } libsqlite3-sys = "0.30.1" rig-core = "0.31.0" rmcp = { version = "0.16.0", features = [ @@ -37,10 +38,12 @@ rmcp = { version = "0.16.0", features = [ schemars = { version = "1.2.1", features = ["chrono04"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +shlex = "1.3.0" sqlite-vec = "0.1.6" sqlx = { version = "0.8.6", features = ["migrate", "sqlite", "runtime-tokio-rustls"] } thiserror = "2.0.18" tempfile = "3.17.1" +toml = "0.9.8" tokio = { version = "1.49.0", features = ["full"] } tokio-retry = "0.3.0" tokio-util = "0.7" @@ -49,3 +52,4 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } url = "2.5.8" ureq = { version = "2.12.1", default-features = false, features = ["json", "tls"] } +which = "7.0.3" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0175969 --- /dev/null +++ b/Makefile @@ -0,0 +1,83 @@ +.DEFAULT_GOAL := help + +.PHONY: help fmt fmt-check check build build-release test test-ci test-cli test-cli-blackbox test-cli-real test-cli-all test-server-retrieval-evals test-server-llm-evals validate install-from-source + +ifneq ($(filter test-server-llm-evals,$(firstword $(MAKECMDGOALS))),) +LLM_EVAL_PROVIDER_ARG := $(word 2,$(MAKECMDGOALS)) +LLM_EVAL_MODEL_ARG := $(word 3,$(MAKECMDGOALS)) + +ifneq ($(strip $(LLM_EVAL_PROVIDER_ARG)),) +export MEMORY_BANK_LLM_PROVIDER := $(LLM_EVAL_PROVIDER_ARG) +$(eval $(LLM_EVAL_PROVIDER_ARG):;@:) +endif + +ifneq ($(strip $(LLM_EVAL_MODEL_ARG)),) +export MEMORY_BANK_LLM_EVAL_MODEL := $(LLM_EVAL_MODEL_ARG) +$(eval $(subst :,\:,$(LLM_EVAL_MODEL_ARG)):;@:) +endif +endif + +help: + @printf "%s\n" \ + "Memory Bank Make targets" \ + "" \ + " make fmt Format the workspace" \ + " make fmt-check Check formatting without rewriting files" \ + " make check Run cargo check for the full workspace" \ + " make build Build the full workspace" \ + " make build-release Build release binaries for the full workspace" \ + " make test Run the default local test suite (includes mb black-box tests)" \ + " make test-ci Run the CI/release validation suite (skips mb black-box tests)" \ + " make test-cli Run memory-bank-cli unit/binary tests only" \ + " make test-cli-blackbox Run mb black-box integration tests only" \ + " make test-cli-real Run opt-in real installed-CLI tests for memory-bank-cli" \ + " make test-cli-all Run all memory-bank-cli tests, including real installed-CLI tests" \ + " make test-server-retrieval-evals Run opt-in real-encoder retrieval evals for memory-bank-server" \ + " make test-server-llm-evals [provider] [model] Run opt-in real LLM functional evals for memory-bank-server" \ + " make validate Run fmt-check, check, and test-ci" \ + " make install-from-source Build and install this checkout into ~/.memory_bank" + +fmt: + cargo fmt --all + +fmt-check: + cargo fmt --all -- --check + +check: + cargo check --workspace + +build: + cargo build --workspace + +build-release: + cargo build --workspace --release + +test: + cargo test --workspace + +test-ci: + cargo test --workspace --exclude memory-bank-cli + cargo test -p memory-bank-cli --lib --bins + +test-cli: + cargo test -p memory-bank-cli --lib --bins + +test-cli-blackbox: + cargo test -p memory-bank-cli --test mb_blackbox + +test-cli-real: + MEMORY_BANK_REAL_BIN_TESTS=1 cargo test -p memory-bank-cli real_ + +test-cli-all: + MEMORY_BANK_REAL_BIN_TESTS=1 cargo test -p memory-bank-cli + +test-server-retrieval-evals: + MEMORY_BANK_RETRIEVAL_EVALS=1 cargo test -p memory-bank-server retrieval_eval:: -- --ignored --nocapture + +test-server-llm-evals: + MEMORY_BANK_LLM_EVALS=1 cargo test -p memory-bank-server llm_eval:: -- --ignored --nocapture + +validate: fmt-check check test-ci + +install-from-source: + ./install.sh --from-source diff --git a/README.md b/README.md index d26bfab..92b3d7c 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,60 @@ -# Memory Bank +

+ Memory Bank logo + Memory Bank +

-Memory Bank gives coding agents shared, long-term memory. +Memory Bank gives coding agents shared, long-term memory across sessions and across tools. -It combines a local MCP server for recall, a lightweight hook binary for capture, and a SQLite-backed memory store that turns prior conversations into reusable notes. Today it supports Claude Code, Gemini CLI, OpenCode, and OpenClaw, so memories captured in one agent can be recalled from another instead of staying trapped inside a single tool's memory system. - -This repository is an implementation inspired by the original [A-MEM](https://arxiv.org/abs/2502.12110) paper, adapted for practical MCP-based agent workflows. - -Currently supports: - -- Claude Code -- Gemini CLI -- OpenCode -- OpenClaw +It runs locally, stores memory in your own namespaced SQLite databases, and works with Claude Code, Gemini CLI, OpenCode, and OpenClaw. ## Why Memory Bank -- Cross-agent continuity. A fact learned in Claude Code, Gemini CLI, OpenCode, or OpenClaw can be recalled later in another supported agent, and vice versa. -- Memory ownership. Your memory store, embeddings, namespaces, and internal analysis model stay under your control instead of being tied to one agent's plugin ecosystem. -- Consistent behavior. You get one memory policy, one retrieval surface, one place to debug capture quality, and one backup or migration story across agents. -- Vendor hedge. If your team changes agents later, the memory corpus stays useful. -- Better recall quality. Memory Bank stores structured turns with user messages, assistant replies, and tool activity so retrieval has richer context than a simple append-only note. -- OpenCode support. OpenCode can use long-term memory through the bundled plugin and the same Memory Bank server. -- OpenClaw support. OpenClaw can capture through the repo-owned extension and recall through the stdio MCP proxy while keeping the same shared Memory Bank backend. +- Shared memory across supported agents instead of siloed memory per tool. +- Local ownership of storage, namespaces, and internal model choice. +- Better continuity from captured user prompts, tool calls, tool results, and assistant replies. +- A simple day-to-day control surface through `mb` instead of manual server management. ## Quick Start -The intended default install path is the repository's GitHub Releases page. The user-facing install flow is the `mb` CLI plus the bootstrap installer at [`./install.sh`](./install.sh). The installer tracks the latest published release and downloads the correct tarball for the current supported macOS or Linux architecture. +The normal install path is the release installer plus `mb setup`. -1. Run the installer. +Install from GitHub Releases: ```bash -./install.sh +sh -c "$(curl -fsSL https://raw.githubusercontent.com/feelingsonice/MemoryBank/main/install.sh)" ``` -2. If you are developing locally and want to start the server without the managed service, run: +If you already cloned this repo and just want to use the local installer script: ```bash -export ANTHROPIC_API_KEY=your-key -./target/release/memory-bank-server --llm-provider anthropic +./install.sh ``` -By default the managed service binds to `127.0.0.1:3737` and exposes: +Then finish setup: + +```bash +mb setup +``` -- MCP: `http://127.0.0.1:3737/mcp` -- Ingest: `http://127.0.0.1:3737/ingest` +`mb setup` walks you through: -The first startup may take a little longer because the embedding model is downloaded and cached locally. +- choosing a namespace +- picking the internal LLM provider and model +- storing the provider secret if needed +- installing and starting the managed background service +- wiring any supported agents it detects on your `PATH` -2. Connect your agent to the MCP endpoint and configure hook-based capture. +Verify that everything is healthy: -Use the agent-specific guides in [`/docs`](./docs): +```bash +mb status +``` -- [Claude Code](./docs/claude-code.md) -- [Gemini CLI](./docs/gemini-cli.md) -- [OpenCode](./docs/opencode.md) -- [OpenClaw](./docs/openclaw.md) +If the installer finished in a non-interactive shell and skipped setup, just run `mb setup` afterward. If `mb` is not available in the current shell yet, use `~/.memory_bank/bin/mb setup` or open a new shell. -The agent you choose here is separate from the `--llm-provider` you chose when starting the server. +If you later change `server.fastembed_model` with `mb config set`, the CLI will ask you to confirm it. The next service start will rebuild the vector index for the active namespace and re-encode any existing memories with the new embedding model. While that runs, `mb status` and `mb service status` will report that Memory Bank is not up yet because it is reindexing. -3. Run a smoke test. +### Smoke Test In a fresh agent session, ask it to remember something memorable and do at least one tool call: @@ -66,338 +62,52 @@ In a fresh agent session, ask it to remember something memorable and do at least Remember that my favorite editor is Helix, then run pwd and summarize what you did. ``` -Then, in the next prompt, ask the agent to retrieve memory before answering: +Then ask it to retrieve memory before answering: ```text Before answering, call retrieve_memory for my editor preference and tell me what you find. ``` -If everything is wired correctly, the agent should call `retrieve_memory` and answer using the stored note. - -## Release Assets - -Each GitHub Release currently publishes three platform-specific tarballs plus `SHA256SUMS`: - -- `memory-bank-aarch64-apple-darwin.tar.gz` -- `memory-bank-x86_64-unknown-linux-gnu.tar.gz` -- `memory-bank-aarch64-unknown-linux-gnu.tar.gz` -- `SHA256SUMS` - -These tarballs extract directly into the `~/.memory_bank/` app-root layout expected by `mb` and `install.sh`. - -Linux note: the release pipeline currently ships native `gnu` builds for modern glibc-based Linux distributions. - -Intel macOS note: release binaries are temporarily unavailable on `x86_64-apple-darwin` because the current FastEmbed and ONNX Runtime dependency chain does not publish a compatible prebuilt package for that target. Intel macOS users can still build from source. - -## Features - -- Cross-agent continuity. Memories captured from Claude Code, Gemini CLI, OpenCode, or OpenClaw can be recalled from the same Memory Bank namespace. -- Memory ownership. Storage, namespaces, embeddings, and internal analysis stay in your environment. -- Consistent behavior. Every supported agent recalls through the same MCP tool: `retrieve_memory`. -- Vendor hedge. Your memory corpus is portable across supported agents instead of being locked to one runtime. -- Better recall and response quality. Retrieved context helps assistants answer with stronger continuity, consistency, and personalization. -- OpenCode memory support. OpenCode can use long-term memory through the bundled plugin and the same Memory Bank server. -- OpenClaw memory support. OpenClaw can use long-term memory through the repo-owned extension for capture and the stdio MCP proxy for recall. -- Local, namespaced storage. Each namespace gets its own SQLite database so you can separate projects, teams, or experiments. -- Hook and plugin based capture. Conversation events are captured through agent hooks or repo-owned plugins/extensions and normalized by `memory-bank-hook`. -- Flexible internal providers. The server can analyze and write memories using Anthropic, Gemini, OpenAI, or Ollama. -- Flexible embedding models. FastEmbed supports built-in models and compatible Hugging Face ONNX repos through `MEMORY_BANK_FASTEMBED_MODEL`. - -## How It Works - -1. Your agent emits conversation events. -2. `memory-bank-hook` normalizes those events and sends them to `POST /ingest`. -3. `memory-bank-server` groups fragments into turns, analyzes them with your configured LLM, and stores memory notes in SQLite. -4. Your agent calls `retrieve_memory` from the MCP endpoint at `http://127.0.0.1:3737/mcp` whenever earlier context could improve the answer. - -Important: MCP handles recall only. To actually capture memories, you also need the hook side configured for your agent. - -Important: the front-facing coding agent does not need to match the server's internal LLM provider. - -## Agent Vs Server Provider - -These are two separate choices, and it is easy to confuse them: - -- The coding agent is the tool you use directly, such as Claude Code, Gemini CLI, OpenCode, or OpenClaw. -- The server LLM provider is what `memory-bank-server` uses internally to analyze turns and write memories, such as Anthropic, Gemini, OpenAI, or Ollama. - -They are independent. For example, you can use Claude Code as your coding agent while running `memory-bank-server --llm-provider gemini`, or use Gemini CLI while the server runs with `--llm-provider anthropic`. +If the setup is working, the agent should call `retrieve_memory` and answer using the stored note. -## Agent Guides +## Supported Agents -Agent-specific documentation lives in [`/docs`](./docs). The main README covers the shared setup and configuration surface; the pages below focus on each agent's own wiring, hooks, and quirks. - -All supported agents can read from the same Memory Bank namespace if you point them at the same server, which is what enables cross-agent continuity. - -That agent choice is separate from the internal model/provider used by the server to create memories. - -| Agent | Capture path | Recall path | Guide | +| Agent | Recall path | Capture path | Guide | | --- | --- | --- | --- | -| Claude Code | Hooks -> `memory-bank-hook` | MCP | [docs/claude-code.md](./docs/claude-code.md) | -| Gemini CLI | Hooks -> `memory-bank-hook` | MCP | [docs/gemini-cli.md](./docs/gemini-cli.md) | -| OpenCode | Bundled plugin -> `memory-bank-hook` | MCP | [docs/opencode.md](./docs/opencode.md) | -| OpenClaw | Native extension -> `memory-bank-hook` | stdio MCP proxy -> Memory Bank MCP | [docs/openclaw.md](./docs/openclaw.md) | - -If you are choosing an agent today, all four are supported. The main difference is how capture is wired: - -- Claude Code uses hooks configured in Claude settings. -- Gemini CLI uses hooks configured in Gemini settings and should keep the Memory Bank hook last in each sequential hook group. -- OpenCode uses the repo-owned plugin at `.opencode/plugins/memory-bank.js`. -- OpenClaw uses the repo-owned extension at `.openclaw/extensions/memory-bank/` and should disable OpenClaw's built-in memory slot when Memory Bank is the primary memory system. - -## What This Repo Builds - -This workspace currently contains five Rust crates: - -| Crate | Type | Purpose | -| --- | --- | --- | -| `memory-bank-cli` | Binary (`mb`) | User-facing control plane for setup, service management, namespace switching, logs, and diagnostics. | -| `memory-bank-server` | Binary | Runs the local HTTP server, hosts `/mcp` and `/ingest`, stores memory, and serves `retrieve_memory`. | -| `memory-bank-hook` | Binary | Reads hook/plugin payloads from stdin, normalizes them, and posts them to the server. | -| `memory-bank-mcp-proxy` | Binary | Exposes `retrieve_memory` as a stdio MCP server and forwards calls to the upstream Memory Bank HTTP MCP server. | -| `memory-bank-protocol` | Library | Shared typed ingest schema and shared retrieve-memory MCP contract used by the binaries. | - -For most users, the only artifacts you need are: - -- `mb` -- `memory-bank-server` -- `memory-bank-hook` -- `memory-bank-mcp-proxy` if you are using OpenClaw - -## Build From Source - -Prebuilt binaries from GitHub Releases should be the easiest install path. If you want to build locally instead, this is the minimum you need. - -### Requirements - -- A recent Rust toolchain with `cargo` -- A working native build toolchain -- An API key for the LLM provider you plan to use, unless you run with Ollama -- Enough disk space for the SQLite database and the embedding model cache +| Claude Code | HTTP MCP | Claude hooks -> `memory-bank-hook` | [Claude Code](./docs/claude-code.md) | +| Gemini CLI | HTTP MCP | Gemini hooks -> `memory-bank-hook` | [Gemini CLI](./docs/gemini-cli.md) | +| OpenCode | HTTP MCP | OpenCode plugin -> `memory-bank-hook` | [OpenCode](./docs/opencode.md) | +| OpenClaw | stdio MCP proxy -> HTTP MCP | OpenClaw extension -> `memory-bank-hook` | [OpenClaw](./docs/openclaw.md) | -Linux users may also need their distro's SQLite development package and `pkg-config` if linking SQLite fails during compilation. +## More Docs -### Build +- [Troubleshooting](./docs/troubleshooting.md) +- [Architecture](./docs/architecture.md) +- [Requirements](./docs/requirements.md) +- [Claude Code Guide](./docs/claude-code.md) +- [Gemini CLI Guide](./docs/gemini-cli.md) +- [OpenCode Guide](./docs/opencode.md) +- [OpenClaw Guide](./docs/openclaw.md) -```bash -cargo build --release --workspace -``` - -Release binaries will be written to: - -- `target/release/memory-bank-server` -- `target/release/memory-bank-hook` -- `target/release/memory-bank-mcp-proxy` -- `target/release/mb` +## How It Works -Useful verification commands: +1. Your agent emits hook, plugin, or extension events. +2. `memory-bank-hook` normalizes those events and sends them to the local Memory Bank service. +3. The service assembles finalized turns, analyzes them with your configured provider, and stores memory notes plus local embeddings. +4. Agents call `retrieve_memory` over MCP when prior context could improve the answer. -```bash -./target/release/mb --help -./target/release/memory-bank-server --help -./target/release/memory-bank-hook --help -./target/release/memory-bank-mcp-proxy --help -cargo test --workspace -``` +Important: the coding agent you use directly is separate from the internal provider Memory Bank uses for memory analysis. For example, you can use Claude Code while Memory Bank runs on Gemini, OpenAI, Anthropic, or Ollama. -### What To Run After Building +## Advanced -Start the server: +If you want to build from source instead of downloading a release, use: ```bash -export ANTHROPIC_API_KEY=your-key -./target/release/memory-bank-server --llm-provider anthropic --encoder-provider fast-embed +./install.sh --from-source ``` -Then follow the guide for your agent in [`/docs`](./docs) to wire MCP plus hook-based capture. - -## Release Process - -GitHub Actions manages CI and releases: - -- CI runs on pull requests targeting `main` and checks formatting, `cargo check`, and `cargo test` -- Releases are built from semver tags like `v0.1.0` -- The release workflow re-runs formatting, `cargo check`, and `cargo test` before any release build or publish step -- The release workflow builds native tarballs for Apple Silicon macOS plus x86_64 and ARM64 Linux -- Releases are created as drafts first, assets are uploaded, and the release is published only after every asset and checksum has been attached - -## Configuration And Environment Variables - -There are four places users typically configure Memory Bank: - -1. Server CLI flags -2. Hook CLI flags -3. Environment variables -4. Agent-specific config files in [`/docs`](./docs) - -Before changing settings, it helps to keep the split clear: - -- Agent configuration controls how Claude Code, Gemini CLI, OpenCode, or OpenClaw sends events to Memory Bank and calls the MCP tool. -- Server LLM configuration controls how Memory Bank analyzes captured turns internally. -- These settings do not need to match. - -### Server CLI Flags - -`memory-bank-server` accepts the following flags: - -| Flag | Default | Description | -| --- | --- | --- | -| `--port` | `3737` | Local HTTP port for the server. MCP is exposed at `/mcp` and ingest is exposed at `/ingest`. | -| `--namespace` | `default` | Storage namespace. Each namespace gets its own SQLite database. | -| `--llm-provider` | `anthropic` | Memory-analysis provider. Supported values: `gemini`, `anthropic`, `open-ai`, `ollama`. | -| `--encoder-provider` | `fast-embed` | Embedding provider. Supported values: `fast-embed`, `local-api`, `remote-api`. Only `fast-embed` is implemented today. | -| `--history-window-size` | `0` | Number of prior stored turns replayed during memory analysis. `0` means unlimited. | -| `--nearest-neighbor-count` | `10` | Number of nearest-neighbor matches loaded during MCP recall and graph evolution. Must be at least `1`. | - -Notes: - -- The server binds to `127.0.0.1` by default. -- Namespace names are sanitized before being used on disk. -- `--llm-provider` configures Memory Bank's internal memory-analysis model, not the coding agent you use in the UI or CLI. -- `--nearest-neighbor-count` lets you tune retrieval breadth at runtime without rebuilding the server. -- `local-api` and `remote-api` are exposed in the config surface, but they are not implemented yet. - -### Hook CLI Flags - -`memory-bank-hook` accepts the following flags: - -| Flag | Default | Description | -| --- | --- | --- | -| `--agent` | required | Source identifier. Supported today: `claude-code`, `gemini-cli`, `openclaw`, `opencode`. | -| `--event` | required | Event name for the incoming hook or plugin payload. | -| `--server-url` | `http://127.0.0.1:3737` | Base URL for the running Memory Bank server. | - -### MCP Proxy CLI Flags - -`memory-bank-mcp-proxy` accepts the following flags: - -| Flag | Default | Description | -| --- | --- | --- | -| `--server-url` | `http://127.0.0.1:3737` | Base URL for the running Memory Bank server. The proxy forwards stdio MCP calls to the server's `/mcp` endpoint and also accepts an explicit `/mcp` URL. | - -### LLM Provider Environment Variables - -These variables configure the server's internal memory-analysis model. They do not configure Claude Code, Gemini CLI, OpenCode, or OpenClaw themselves. - -| Provider | Required | Optional | Default model/value | -| --- | --- | --- | --- | -| Anthropic | `ANTHROPIC_API_KEY` | `MEMORY_BANK_LLM_MODEL` | `claude-sonnet-4-6` | -| Gemini | `GEMINI_API_KEY` | `MEMORY_BANK_LLM_MODEL` | `gemini-2.5-flash` | -| OpenAI | `OPENAI_API_KEY` | `MEMORY_BANK_LLM_MODEL` | `gpt-4o-mini` | -| Ollama | none | `MEMORY_BANK_OLLAMA_URL`, `MEMORY_BANK_OLLAMA_MODEL` | `http://localhost:11434`, `qwen3:4b` | - -Ollama note: on startup the server verifies that the configured URL points at the native Ollama API root and that the configured model already exists locally. - -### Encoder Environment Variables - -| Variable | Used by | Default | Description | -| --- | --- | --- | --- | -| `MEMORY_BANK_FASTEMBED_MODEL` | `--encoder-provider fast-embed` | `jinaai/jina-embeddings-v2-base-code` | Embedding model ID used by FastEmbed. | -| `MEMORY_BANK_LOCAL_ENCODER_URL` | `--encoder-provider local-api` | none | Reserved config for a local encoder API. Not implemented yet. | -| `MEMORY_BANK_REMOTE_ENCODER_API_KEY` | `--encoder-provider remote-api` | none | Reserved config for a remote encoder API. Not implemented yet. | -| `MEMORY_BANK_REMOTE_ENCODER_URL` | `--encoder-provider remote-api` | none | Reserved config for a remote encoder API. Not implemented yet. | - -When you use `--encoder-provider fast-embed`, Memory Bank resolves `MEMORY_BANK_FASTEMBED_MODEL` in two stages: - -- First it tries to interpret the value as a FastEmbed-native model name. -- If that does not match a built-in FastEmbed model, it treats the value as a Hugging Face repo ID and tries to load a compatible ONNX model from that repository. - -In practice, this means you can use either: - -- a model name that FastEmbed already supports natively -- a compatible Hugging Face ONNX embedding repo, as long as `MEMORY_BANK_FASTEMBED_MODEL` is set to the correct repo string - -For Hugging Face ONNX repos, Memory Bank currently expects the repo to expose: - -- `onnx/model.onnx` or `model.onnx` -- `tokenizer.json` -- `config.json` -- `special_tokens_map.json` -- `tokenizer_config.json` - -If the model string is correct and the repo has that layout, Memory Bank will download and cache it automatically under the local models directory. - -### OpenCode Plugin Environment Variables - -These are only relevant if you are using the bundled OpenCode plugin: - -| Variable | Default | Description | -| --- | --- | --- | -| `MEMORY_BANK_HOOK_BIN` | `~/.memory_bank/bin/memory-bank-hook` | Overrides which hook binary the plugin executes. | -| `MEMORY_BANK_SERVER_URL` | `http://127.0.0.1:3737` | Overrides where the plugin sends hook fragments. | -| `MEMORY_BANK_OPENCODE_DEBUG` | unset | Enables verbose plugin diagnostics when set to `1`. | -| `MEMORY_BANK_OPENCODE_DEBUG_FILE` | unset | If set, also mirrors plugin logs to a file. | - -### OpenClaw Extension Environment Variables - -These are only relevant if you are using the repo-owned OpenClaw extension: - -| Variable | Default | Description | -| --- | --- | --- | -| `MEMORY_BANK_HOOK_BIN` | `~/.memory_bank/bin/memory-bank-hook` | Fallback override for which hook binary the extension executes. OpenClaw plugin config is the primary config surface. | -| `MEMORY_BANK_SERVER_URL` | `http://127.0.0.1:3737` | Fallback override for where the extension sends hook fragments and where `memory-bank-mcp-proxy` forwards recall calls. | -| `MEMORY_BANK_OPENCLAW_DEBUG` | unset | Enables verbose OpenClaw extension diagnostics when set to `1`. | -| `MEMORY_BANK_OPENCLAW_DEBUG_FILE` | unset | If set, also mirrors OpenClaw extension logs to a file. | - -### Logging - -Both binaries respect `RUST_LOG`. - -| Variable | Default | Description | -| --- | --- | --- | -| `RUST_LOG` | `info` | Controls log verbosity for `memory-bank-server` and `memory-bank-hook`. | - -### Agent Config Files - -| Agent | Project-scoped config | User-scoped config | Notes | -| --- | --- | --- | --- | -| Claude Code | `.claude/settings.local.json` | `~/.claude/settings.json` | MCP registration and hook configuration are separate concerns. | -| Gemini CLI | `.gemini/settings.json` | `~/.gemini/settings.json` | Keep the Memory Bank hook last in each relevant sequential hook group. | -| OpenCode | `opencode.json` plus `.opencode/plugins/memory-bank.js` | `~/.config/opencode/opencode.json` plus `~/.config/opencode/plugins/` | MCP is configured in `opencode.json`; capture is handled by the plugin. | -| OpenClaw | `.openclaw/extensions/memory-bank/` plus workspace plugin config | `~/.openclaw/openclaw.json` | MCP is configured through OpenClaw's saved MCP server definitions; capture is handled by the repo-owned extension. | - -The exact setup steps and examples for those files live in [`/docs`](./docs). - -## Storage Layout - -Memory Bank stores data under your home directory in a top-level `.memory_bank` folder. - -- App root: `{home_dir}/.memory_bank/` -- Namespaces: `{home_dir}/.memory_bank/namespaces//` -- Database: `{home_dir}/.memory_bank/namespaces//memory.db` -- Model cache: `{home_dir}/.memory_bank/models/` - -Examples: - -- macOS: `~/.memory_bank/` -- Linux: `~/.memory_bank/` -- Windows: `%USERPROFILE%/.memory_bank/` - -This means you can isolate experiments, teams, or projects by running separate namespaces. - -## Current Scope - -The current implementation is intentionally focused: - -- One recall tool is exposed over MCP: `retrieve_memory` -- Claude Code, Gemini CLI, OpenCode, and OpenClaw are the supported agent integrations today -- `fast-embed` is the only implemented encoder provider today -- OpenCode support currently relies on the bundled plugin in this repository -- OpenClaw support currently relies on the bundled workspace extension plus the stdio MCP proxy in this repository +That is the advanced path. The lower-level binaries still exist, but `mb` and the built-in `--help` pages are the intended user interface. ## License -This project is licensed under `MIT`. See [LICENSE](./LICENSE) for the full text. - -## Citation - -If this project is useful in your work, please cite the original A-MEM paper: - -```bibtex -@article{xu2025mem, - title={{A-MEM}: Agentic Memory for {LLM} Agents}, - author={Xu, Wujiang and Liang, Zujie and Mei, Kai and Gao, Hang and Tan, Juntao and Zhang, Yongfeng}, - journal={arXiv preprint arXiv:2502.12110}, - year={2025} -} -``` +Memory Bank is licensed under `MIT`. See [LICENSE](./LICENSE). diff --git a/config/setup-model-catalog.json b/config/setup-model-catalog.json new file mode 100644 index 0000000..f48e40e --- /dev/null +++ b/config/setup-model-catalog.json @@ -0,0 +1,27 @@ +{ + "providers": { + "anthropic": { + "models": ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"] + }, + "gemini": { + "models": [ + "gemini-3.1-pro-preview", + "gemini-3-flash-preview", + "gemini-3.1-flash-lite-preview" + ] + }, + "open-ai": { + "models": ["gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano"] + }, + "ollama": { + "models": [ + "qwen3", + "deepseek-r1", + "llama3.1", + "qwen2.5-coder", + "gemma3", + "mistral-small3.1" + ] + } + } +} diff --git a/docs/architecture-diagram.svg b/docs/architecture-diagram.svg deleted file mode 100644 index a4ebc10..0000000 --- a/docs/architecture-diagram.svg +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - Memory Bank Architecture - A simplified view for users: capture conversations into memory, then recall them later through MCP. - - - Capture and recall are two separate paths - - - Any supported agent can share the same memories - - - 1. Capture Path - This is how new conversations get saved into Memory Bank. - - - Your Agent - - Claude Code, Gemini CLI, - or OpenCode - - - - Hooks or Plugin - - Capture prompts, tool use, - and final replies - - - - memory-bank-hook - - Normalizes the event and - sends it to the server - - - - Memory Bank Server - - Groups conversation fragments - into memory notes and stores them - - - - - - - - POST /ingest - - - 2. Recall Path - This is how an agent pulls earlier context back into a new answer. - - - Your Agent - - Calls retrieve_memory - through MCP - - - - Memory Bank Server - - Searches stored memories for - the most relevant past context - - - - Stored Memory - - SQLite-backed notes - One database per namespace - Shared by supported agents - - - - Results - - Relevant memory notes - returned to the agent - - - - - - - - MCP: /mcp - - - Supported today: Claude Code, Gemini CLI, OpenCode, and OpenClaw. The server can use Anthropic, Gemini, OpenAI, or Ollama internally. - diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..65b5440 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,101 @@ +# Architecture + +Memory Bank is a local memory service for coding agents. + +At a high level, it separates memory into two paths: + +- capture: collect conversation events from the agent +- recall: let the agent query long-term memory with `retrieve_memory` + +## The Main Pieces + +- `mb` + - the user-facing CLI for setup, status, logs, namespace switching, and diagnostics +- the managed Memory Bank service + - a local background process that exposes `/ingest`, `/mcp`, and `/healthz` +- `memory-bank-hook` + - a lightweight binary that normalizes hook, plugin, and extension payloads into a shared ingest format +- the local data store + - namespaced SQLite databases plus a local embedding-model cache under `~/.memory_bank/` + +## Memory Lifecycle + +1. Your agent emits events. + + Depending on the agent, those events come from hooks, a plugin, or an extension. + +2. Memory Bank captures the turn. + + `memory-bank-hook` normalizes the raw event into an ingest payload and sends it to the local service. + +3. The service assembles a finalized turn. + + User prompts, tool calls, tool results, and the final assistant reply are grouped into a single durable turn. + +4. Memory Bank analyzes the turn. + + The configured provider turns that finalized conversation window into a structured memory note with context, keywords, and tags. When similar existing memories are found, Memory Bank may also run a second graph-evolution step to update links and refine tags. + +5. Memory Bank stores the result locally. + + The memory note goes into the namespace's SQLite database, and a local embedding is stored for retrieval. + +6. Future agent sessions retrieve memory over MCP. + + When the agent calls `retrieve_memory`, Memory Bank embeds the query, finds nearby notes, expands linked memories, and returns ranked memory results. + +## Capture And Recall Are Different + +The capture path depends on the agent: + +- Claude Code: hooks +- Gemini CLI: hooks +- OpenCode: plugin +- OpenClaw: extension + +The recall path is more uniform: + +- most agents talk directly to Memory Bank over HTTP MCP +- OpenClaw uses a local stdio proxy that forwards to the same HTTP MCP endpoint + +This is why an agent can have recall working while capture is broken, or vice versa. + +## Namespaces + +Namespaces let you keep separate memory stores for different projects, teams, or experiments. + +Each namespace gets its own SQLite database under: + +```text +~/.memory_bank/namespaces//memory.db +``` + +Use: + +```bash +mb namespace list +mb namespace use +``` + +When you switch namespaces, Memory Bank starts using a different local database immediately. + +## What Memory Bank Is Not + +Memory Bank is not a reverse proxy sitting in front of your coding agent's model traffic. + +Instead: + +- capture comes from agent hooks, plugins, or extensions +- recall comes from the `retrieve_memory` MCP tool +- the internal Memory Bank provider is separate from the coding agent you use directly + +That separation is what makes cross-agent memory possible. You can use one tool as your coding agent and a different provider for Memory Bank's internal memory analysis. + +## Related Docs + +- [Requirements](./requirements.md) +- [Troubleshooting](./troubleshooting.md) +- [Claude Code](./claude-code.md) +- [Gemini CLI](./gemini-cli.md) +- [OpenCode](./opencode.md) +- [OpenClaw](./openclaw.md) diff --git a/docs/claude-code.md b/docs/claude-code.md index 97e8272..f967dd1 100644 --- a/docs/claude-code.md +++ b/docs/claude-code.md @@ -1,118 +1,43 @@ # Claude Code Guide -This guide covers the Claude Code specific wiring for Memory Bank. +Memory Bank integrates with Claude Code in two ways: -For building the binaries, starting `memory-bank-server`, and the shared configuration surface, use the main [README](../README.md). +- recall through a user-scoped HTTP MCP server +- capture through Claude hooks that call `memory-bank-hook` -## How Claude Code Connects +The normal path is `mb setup`. You do not need repo-local Claude config for the supported setup. -Claude Code uses Memory Bank in two separate ways: +## What `mb setup` Configures -- Recall uses MCP. Claude Code connects to `http://127.0.0.1:3737/mcp` and can call `retrieve_memory`. -- Capture uses Claude hooks. Those hooks shell out to `memory-bank-hook`, which forwards normalized fragments to `POST /ingest`. +`mb setup` will: -## Hooks Used +- register a user-scoped Claude MCP server named `memory-bank` +- update `~/.claude/settings.json` +- add Memory Bank hooks for: + - `UserPromptSubmit` + - `PreToolUse` + - `PostToolUse` + - `Stop` +- point those hooks at the managed binary under `~/.memory_bank/bin/memory-bank-hook` -| Claude hook | Stored as | Notes | -| --- | --- | --- | -| `UserPromptSubmit` | user message | Captures the user's prompt text. | -| `PreToolUse` | tool call | Captures tool name plus tool input JSON. | -| `PostToolUse` | tool result | Captures tool name plus tool output JSON. | -| `Stop` | assistant message | Final assistant fragment. This is a hard terminal fragment only when `stop_hook_active=false`. | +If `~/.claude/settings.json` already exists, Memory Bank backs it up before rewriting it. -## Project Setup +## Files And Settings It Touches -1. Make sure Memory Bank is already running. - -Use the main [README Quick Start](../README.md#quick-start) or [Build From Source](../README.md#build-from-source) first. - -2. Register the MCP server in Claude Code. - -```bash -claude mcp add --transport http --scope local memory-bank http://127.0.0.1:3737/mcp -``` - -3. Add the Memory Bank hooks to `.claude/settings.local.json`. - -Replace `/absolute/path/to/memory-bank-hook` with the absolute path to your `memory-bank-hook` binary. - -```json -{ - "hooks": { - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "/absolute/path/to/memory-bank-hook --agent claude-code --event UserPromptSubmit --server-url http://127.0.0.1:3737" - } - ] - } - ], - "PreToolUse": [ - { - "hooks": [ - { - "type": "command", - "command": "/absolute/path/to/memory-bank-hook --agent claude-code --event PreToolUse --server-url http://127.0.0.1:3737" - } - ] - } - ], - "PostToolUse": [ - { - "hooks": [ - { - "type": "command", - "command": "/absolute/path/to/memory-bank-hook --agent claude-code --event PostToolUse --server-url http://127.0.0.1:3737" - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "/absolute/path/to/memory-bank-hook --agent claude-code --event Stop --server-url http://127.0.0.1:3737" - } - ] - } - ] - } -} -``` - -If the file already contains other top-level keys such as `permissions`, keep them and add `hooks` alongside them. - -4. Restart Claude Code in this repo, or use `/hooks` to confirm the hooks loaded. - -## Machine-Wide Setup - -If you want the same Memory Bank setup in every Claude Code session on this machine: - -- register MCP at user scope: - -```bash -claude mcp add --transport http --scope user memory-bank http://127.0.0.1:3737/mcp -``` - -- put the same `hooks` object in `~/.claude/settings.json` - -Claude Code quirk: user-scoped MCP servers and user-scoped hooks are configured separately. `claude mcp add --scope user` manages the global MCP entry, while hook commands still come from `~/.claude/settings.json`. +- Claude's user-scoped MCP registry entry for `memory-bank` +- `~/.claude/settings.json` ## Claude Code Quirks -- Use an absolute path for `memory-bank-hook`. Hooks may run from contexts where relative paths are unreliable. -- A turn is only ready for memory analysis after a hard `Stop`. -- If `stop_hook_active=true`, the `Stop` fragment is treated as soft and the turn is not finalized yet. -- Settings precedence still applies. Project-local settings override broader Claude settings. +- Claude's MCP registration and hook configuration are separate. Full Memory Bank behavior needs both recall and capture. +- `mb setup` expects the `memory-bank` MCP server to live at user scope. A conflicting project-scoped or local-scoped `memory-bank` entry can block setup until you remove or rename it. +- Memory Bank finalizes Claude turns on `Stop`. If Claude reports `stop_hook_active=true`, that stop event is treated as soft and the turn may not finalize yet. +- Restart Claude Code, or check `/mcp` and `/hooks`, after setup. ## Verify Useful checks: -- `claude mcp list` - `claude mcp get memory-bank` - `/mcp` - `/hooks` @@ -129,4 +54,4 @@ Then ask: Before answering, call retrieve_memory for my editor preference and tell me what you find. ``` -You can manually delete Claude Code's memory notes if you'd like. +If you need general fixes, use [Troubleshooting](./troubleshooting.md). For platform support and provider notes, see [Requirements](./requirements.md). diff --git a/docs/gemini-cli.md b/docs/gemini-cli.md index a18d2f5..8bcc46c 100644 --- a/docs/gemini-cli.md +++ b/docs/gemini-cli.md @@ -1,136 +1,44 @@ # Gemini CLI Guide -This guide covers the Gemini CLI specific wiring for Memory Bank. +Memory Bank integrates with Gemini CLI in two ways: -For building the binaries, starting `memory-bank-server`, and the shared configuration surface, use the main [README](../README.md). +- recall through HTTP MCP +- capture through Gemini hooks that call `memory-bank-hook` -## How Gemini CLI Connects +The supported setup is user-scoped and driven by `mb setup`. -Gemini CLI uses Memory Bank in two separate ways: +## What `mb setup` Configures -- Recall uses MCP. Gemini CLI connects to `http://127.0.0.1:3737/mcp` and can call `retrieve_memory`. -- Capture uses Gemini hooks. Those hooks shell out to `memory-bank-hook`, which forwards normalized fragments to `POST /ingest`. +`mb setup` will update `~/.gemini/settings.json` and add: -## Hooks Used +- a `memory-bank` MCP server pointing at the local Memory Bank service +- hook entries for: + - `BeforeAgent` + - `BeforeTool` + - `AfterTool` + - `AfterAgent` -| Gemini hook | Stored as | Notes | -| --- | --- | --- | -| `BeforeAgent` | user message | Captures the user's prompt text. | -| `BeforeTool` | tool call | Captures tool name plus tool input JSON. | -| `AfterTool` | tool result | Captures tool name plus tool output JSON. | -| `AfterAgent` | assistant message | Final assistant fragment. This is a hard terminal fragment only when `stop_hook_active=false`. | +It also writes the managed hook binary path under `~/.memory_bank/bin/memory-bank-hook`. -## Project Setup +If `~/.gemini/settings.json` already exists, Memory Bank backs it up before rewriting it. -1. Make sure Memory Bank is already running. +## Files And Settings It Touches -Use the main [README Quick Start](../README.md#quick-start) or [Build From Source](../README.md#build-from-source) first. - -2. Register the MCP server in Gemini CLI. - -```bash -gemini mcp add --scope project --transport http memory-bank http://127.0.0.1:3737/mcp -``` - -3. Add the Memory Bank MCP entry and hooks to `.gemini/settings.json`. - -Replace `/absolute/path/to/memory-bank-hook` with the absolute path to your `memory-bank-hook` binary. - -```json -{ - "mcpServers": { - "memory-bank": { - "httpUrl": "http://127.0.0.1:3737/mcp" - } - }, - "hooks": { - "BeforeAgent": [ - { - "matcher": "*", - "sequential": true, - "hooks": [ - { - "name": "memory-bank", - "type": "command", - "command": "/absolute/path/to/memory-bank-hook --agent gemini-cli --event BeforeAgent --server-url http://127.0.0.1:3737" - } - ] - } - ], - "BeforeTool": [ - { - "matcher": ".*", - "sequential": true, - "hooks": [ - { - "name": "memory-bank", - "type": "command", - "command": "/absolute/path/to/memory-bank-hook --agent gemini-cli --event BeforeTool --server-url http://127.0.0.1:3737" - } - ] - } - ], - "AfterTool": [ - { - "matcher": ".*", - "sequential": true, - "hooks": [ - { - "name": "memory-bank", - "type": "command", - "command": "/absolute/path/to/memory-bank-hook --agent gemini-cli --event AfterTool --server-url http://127.0.0.1:3737" - } - ] - } - ], - "AfterAgent": [ - { - "matcher": "*", - "sequential": true, - "hooks": [ - { - "name": "memory-bank", - "type": "command", - "command": "/absolute/path/to/memory-bank-hook --agent gemini-cli --event AfterAgent --server-url http://127.0.0.1:3737" - } - ] - } - ] - } -} -``` - -4. Restart Gemini CLI in this repo so it reloads the settings. - -## Machine-Wide Setup - -If you want the same Memory Bank setup in every Gemini CLI session on this machine: - -- register MCP at user scope: - -```bash -gemini mcp add --scope user --transport http memory-bank http://127.0.0.1:3737/mcp -``` - -- move the same `mcpServers` and `hooks` config into `~/.gemini/settings.json` - -Repo-local `.gemini/settings.json` still overrides `~/.gemini/settings.json`. +- `~/.gemini/settings.json` ## Gemini CLI Quirks -- Keep `sequential: true` on each Memory Bank hook group. -- If you already use hooks that can validate, rewrite, redact, deny, or retry the same event, put those hooks before Memory Bank. -- Keep the `memory-bank` hook last in each relevant hook group so Gemini's final version of the event is what gets captured. -- Use an absolute path for `memory-bank-hook`. -- A turn is only ready for memory analysis after a hard `AfterAgent`. -- If `stop_hook_active=true`, the `AfterAgent` fragment is treated as soft and the turn is not finalized yet. +- Gemini capture depends on hook ordering. If you already use other hooks that transform, redact, deny, or retry events, keep those before the Memory Bank hook so Memory Bank sees the final version. +- Memory Bank expects its hook groups to stay `sequential: true`. +- Memory Bank finalizes Gemini turns on `AfterAgent`. If Gemini reports `stop_hook_active=true`, that event is treated as soft and the turn may not finalize yet. +- Restart Gemini CLI after setup so it reloads the updated settings file. ## Verify Useful checks: - `gemini mcp list` -- inspect `.gemini/settings.json` +- inspect `~/.gemini/settings.json` Simple smoke test: @@ -144,4 +52,4 @@ Then ask: Before answering, call retrieve_memory for my editor preference and tell me what you find. ``` -You can manually delete Gemini CLI's memory notes if you'd like. +If you need general fixes, use [Troubleshooting](./troubleshooting.md). For platform support and provider notes, see [Requirements](./requirements.md). diff --git a/docs/logo.svg b/docs/logo.svg new file mode 100644 index 0000000..6313e6b --- /dev/null +++ b/docs/logo.svg @@ -0,0 +1,28 @@ + + Memory Bank logo + Three memory fragments converging into a shared vault-like memory bank. + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/openclaw.md b/docs/openclaw.md index 69d02df..11fac74 100644 --- a/docs/openclaw.md +++ b/docs/openclaw.md @@ -1,193 +1,49 @@ # OpenClaw Guide -This guide covers the OpenClaw-specific wiring for Memory Bank. +OpenClaw is the most different integration because recall goes through a local stdio proxy instead of talking to the HTTP MCP endpoint directly. -OpenClaw is different from Claude Code, Gemini CLI, and OpenCode in one important way: its native runtime currently expects stdio MCP servers, not a remote HTTP MCP endpoint. That means OpenClaw uses Memory Bank in two separate ways: +In practice that means: -- Recall uses the `memory-bank-mcp-proxy` stdio MCP bridge. -- Capture uses the repo-owned OpenClaw extension at `.openclaw/extensions/memory-bank/`, which shells out to `memory-bank-hook`. +- recall uses `memory-bank-mcp-proxy` +- capture uses the bundled OpenClaw Memory Bank extension -For building the binaries, starting `memory-bank-server`, and the shared configuration surface, use the main [README](../README.md). +The supported setup is user-scoped and handled by `mb setup`. -## How OpenClaw Connects +## What `mb setup` Configures -OpenClaw uses Memory Bank in two separate ways: +`mb setup` will update `~/.openclaw/openclaw.json` and: -- Recall uses the stdio `memory-bank-mcp-proxy`, which forwards `retrieve_memory` calls to `http://127.0.0.1:3737/mcp`. -- Capture uses the repo-owned OpenClaw extension. The extension listens to OpenClaw lifecycle hooks, shells out to `memory-bank-hook`, and forwards normalized fragments to `POST /ingest`. -- The extension also injects a short system reminder telling OpenClaw to prefer `retrieve_memory` for durable memory and provenance questions such as "how do you know?". +- add a `memory-bank` MCP server that launches `memory-bank-mcp-proxy` +- point that proxy at the local Memory Bank service +- add the bundled Memory Bank extension load path under `~/.memory_bank/integrations/openclaw/memory-bank` +- enable the `memory-bank` plugin entry +- point the plugin at the managed `memory-bank-hook` binary +- set `plugins.slots.memory` to `none` -Without the extension, OpenClaw can still retrieve from Memory Bank through the proxy, but it will not capture new memories. +If `~/.openclaw/openclaw.json` already exists, Memory Bank backs it up before rewriting it. -## Events Used +## Files And Settings It Touches -| OpenClaw source | Sent through hook as | Stored as | Notes | -| --- | --- | --- | --- | -| `before_model_resolve` | `before_model_resolve` | user message | Primary prompt-capture hook. Runs before model resolution and is not gated by prompt-injection policy. | -| `before_prompt_build` | `before_prompt_build` | user message | Fallback prompt capture when the pre-session phase lacks enough context to emit safely. Also injects the Memory Bank preference guidance. | -| `before_tool_call` | `before_tool_call` | tool call | Captures tool name plus tool arguments JSON. | -| `after_tool_call` | `after_tool_call` | tool result | Captures tool name plus tool output JSON, including tool error metadata when OpenClaw surfaces it. | -| `agent_end` | `agent_end` | assistant message | Captures the final assistant reply as a hard terminal fragment. | - -The extension intentionally does not capture: - -- raw message transit hooks -- reasoning -- compaction -- OpenClaw's own memory internals -- empty prompts or empty final assistant messages - -## Recommended Setup - -1. Make sure Memory Bank is already running. - -Use the main [README Quick Start](../README.md#quick-start) or [Build From Source](../README.md#build-from-source) first. - -2. Register the Memory Bank MCP proxy with OpenClaw. - -```bash -openclaw mcp set memory-bank '{"command":"/absolute/path/to/memory-bank-mcp-proxy","args":["--server-url","http://127.0.0.1:3737"]}' -``` - -This stores a stdio MCP server definition under OpenClaw's MCP registry. The proxy also advertises Memory Bank as the preferred long-term memory source for prior-session recall inside OpenClaw. - -`memory-bank-mcp-proxy --server-url` accepts either: - -- the base server URL, such as `http://127.0.0.1:3737` -- or the explicit MCP URL, such as `http://127.0.0.1:3737/mcp` - -This guide uses the base server URL form because the proxy appends `/mcp` automatically when needed. - -3. Install the repo-owned OpenClaw extension. - -From this repository root: - -```bash -openclaw plugins install -l ./.openclaw/extensions/memory-bank -``` - -This native plugin keeps a minimal local-install: - -- `package.json` -- `openclaw.plugin.json` -- `index.js` - -The `package.json` is required for `openclaw plugins install` on current OpenClaw releases because the installer looks for `openclaw.extensions` when validating local plugin paths. - -4. Enable the extension and make Memory Bank the primary memory system. - -Update `~/.openclaw/openclaw.json` to include: - -```json -{ - "mcp": { - "servers": { - "memory-bank": { - "command": "/absolute/path/to/memory-bank-mcp-proxy", - "args": ["--server-url", "http://127.0.0.1:3737"] - } - } - }, - "plugins": { - "entries": { - "memory-bank": { - "enabled": true, - "config": { - "hookBinary": "/absolute/path/to/memory-bank-hook", - "serverUrl": "http://127.0.0.1:3737" - } - } - }, - "slots": { - "memory": "none" - } - } -} -``` - -Important: setting `plugins.slots.memory` to `"none"` is the recommended v1 setup. It avoids split-brain behavior between OpenClaw's built-in memory plugins and Memory Bank. - -5. Restart OpenClaw so it reloads the saved MCP server definitions and the extension config. - -If you are running the managed gateway service: - -```bash -openclaw gateway restart -``` - -If you are running OpenClaw in the foreground instead, stop that process and start it again with: - -```bash -openclaw gateway run -``` - -## Workspace-Local Development - -The supported v1 path is workspace-local development from this repository. - -The extension lives at: - -```text -.openclaw/extensions/memory-bank/ -``` - -OpenClaw can also read the same `memory-bank` extension after installation from another workspace, as long as the extension path and binary paths resolve correctly. - -## Extension Config - -The OpenClaw extension uses a hybrid config model: - -- Primary: OpenClaw plugin config in `~/.openclaw/openclaw.json` -- Fallback: environment variables - -Supported config keys: - -| Key | Description | -| --- | --- | -| `serverUrl` | Base URL for the running Memory Bank server. | -| `hookBinary` | Absolute path to `memory-bank-hook`. | -| `debug` | Enables verbose extension diagnostics. | -| `debugFile` | Optional log file path for extension diagnostics. | - -Supported fallback environment variables: - -| Variable | Description | -| --- | --- | -| `MEMORY_BANK_SERVER_URL` | Fallback base URL for the running Memory Bank server. | -| `MEMORY_BANK_HOOK_BIN` | Fallback path to `memory-bank-hook`. | -| `MEMORY_BANK_OPENCLAW_DEBUG` | Enables verbose extension diagnostics when set to `1`. | -| `MEMORY_BANK_OPENCLAW_DEBUG_FILE` | Mirrors extension logs to a file. | +- `~/.openclaw/openclaw.json` +- the extension assets under `~/.memory_bank/integrations/openclaw/memory-bank` ## OpenClaw Quirks -- Recall goes through a stdio proxy, not directly to the HTTP `/mcp` endpoint. +- Recall goes through the local stdio proxy, not directly to `http://127.0.0.1:3737/mcp`. - Capture is extension-based, not hook-block based. -- Prompt capture uses the modern OpenClaw hook split: `before_model_resolve` for prompt capture and `before_prompt_build` for prompt shaping plus fallback capture. -- The recommended setup disables OpenClaw's built-in memory slot so Memory Bank is the only long-term memory system in play. -- Even with the built-in memory slot disabled, OpenClaw workspace files like `USER.md` and `MEMORY.md` can still exist and be loaded by the host. The proxy instructions and plugin prompt injection are there to bias OpenClaw toward `retrieve_memory` as the primary durable memory source. -- If `plugins.entries.memory-bank.hooks.allowPromptInjection=false`, OpenClaw blocks the prompt-shaping hook but still allows prompt capture through `before_model_resolve`. -- The extension is written to be defensive about hook payload shape and skips incomplete events instead of crashing the runtime. -- If `memory-bank-hook` is missing, the extension logs a warning and skips capture instead of crashing OpenClaw. +- The extension captures prompt information before model resolution and captures the final assistant reply on `agent_end`. +- The supported setup disables OpenClaw's built-in memory slot with `plugins.slots.memory = "none"` to avoid split-brain behavior between OpenClaw memory plugins and Memory Bank. +- After setup, restart OpenClaw or its gateway so the new MCP and extension settings are picked up. ## Verify Useful checks: - `openclaw mcp list` -- confirm `~/.openclaw/openclaw.json` contains the `memory-bank` MCP server definition +- inspect `~/.openclaw/openclaw.json` - confirm the `memory-bank` plugin entry is enabled -- confirm `plugins.slots.memory` is set to `"none"` in the recommended setup - -Simple smoke test: - -```text -Remember that my favorite editor is Helix, then run pwd and summarize what you did. -``` - -Then ask: +- confirm `plugins.slots.memory` is set to `none` -```text -Before answering, use the memory-bank retrieve_memory tool to look up my editor preference and tell me what you find. -``` +Then restart OpenClaw and run the same smoke test used in the main README. -If everything is wired correctly, OpenClaw should call `retrieve_memory` through the stdio proxy and answer using the stored note. +If you need general fixes, use [Troubleshooting](./troubleshooting.md). For platform support and provider notes, see [Requirements](./requirements.md). diff --git a/docs/opencode.md b/docs/opencode.md index 156319d..fb66f2f 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -1,125 +1,45 @@ # OpenCode Guide -This guide covers the OpenCode specific wiring for Memory Bank. +OpenCode uses Memory Bank a little differently from Claude Code and Gemini CLI: -OpenCode is different from Claude Code and Gemini CLI. Recall still uses MCP, but capture is handled by a plugin rather than a hook block in a settings file. +- recall still uses HTTP MCP +- capture is plugin-based instead of hook-block based -For building the binaries, starting `memory-bank-server`, and the shared configuration surface, use the main [README](../README.md). +The normal path is `mb setup`. Manual per-project plugin copying is no longer the default user flow. -## How OpenCode Connects +## What `mb setup` Configures -OpenCode uses Memory Bank in two separate ways: +`mb setup` will: -- Recall uses MCP. OpenCode connects to `http://127.0.0.1:3737/mcp` and can call `retrieve_memory`. -- Capture uses the Memory Bank plugin at `.opencode/plugins/memory-bank.js`. The plugin listens to OpenCode events, shells out to `memory-bank-hook`, and forwards normalized fragments to `POST /ingest`. +- copy the bundled Memory Bank plugin to `~/.config/opencode/plugins/memory-bank.js` +- update `~/.config/opencode/opencode.json` +- add a `memory-bank` MCP entry pointing at the local Memory Bank service -Without the plugin, OpenCode can still retrieve from Memory Bank through MCP, but it will not capture new memories. +The plugin then shells out to the managed hook binary under `~/.memory_bank/bin/memory-bank-hook`. -## Events Used +If `~/.config/opencode/opencode.json` already exists, Memory Bank backs it up before rewriting it. -| OpenCode source | Sent through hook as | Stored as | Notes | -| --- | --- | --- | --- | -| `chat.message` | `message.updated` | user message | Only non-summary, non-reverted user messages with text are captured. | -| `tool.execute.before` | `tool.execute.before` | tool call | Captures tool name plus tool arguments JSON. | -| `tool.execute.after` | `tool.execute.after` | tool result | Captures tool name plus tool output JSON. | -| `session.idle` | `session.idle` | assistant message | The plugin resolves the latest assistant reply and emits a hard terminal fragment. | +## Files And Settings It Touches -The plugin intentionally does not capture: - -- assistant `message.updated` streaming events -- reasoning -- `file.edited` -- `command.executed` -- deleted or reverted message retractions -- undocumented or experimental hooks - -## Project Setup - -1. Make sure Memory Bank is already running. - -Use the main [README Quick Start](../README.md#quick-start) or [Build From Source](../README.md#build-from-source) first. - -2. Add the MCP server to `opencode.json`. - -```json -{ - "$schema": "https://opencode.ai/config.json", - "mcp": { - "memory-bank": { - "type": "remote", - "url": "http://127.0.0.1:3737/mcp", - "enabled": true - } - } -} -``` - -3. Make sure the plugin is available. - -If you are running OpenCode inside this repository, the plugin already exists at `.opencode/plugins/memory-bank.js` and OpenCode will load it automatically. - -If you want to use Memory Bank from another OpenCode project, copy or symlink this plugin into that project's `.opencode/plugins/` directory: - -```bash -mkdir -p /path/to/your-project/.opencode/plugins -ln -sf /absolute/path/to/a-mem-mcp/.opencode/plugins/memory-bank.js /path/to/your-project/.opencode/plugins/memory-bank.js -``` - -4. Make sure the plugin can find `memory-bank-hook`. - -By default, the plugin looks for: - -- `./target/debug/memory-bank-hook` -- then `./target/release/memory-bank-hook` - -If your binary lives somewhere else, set: - -```bash -export MEMORY_BANK_HOOK_BIN=/absolute/path/to/memory-bank-hook -export MEMORY_BANK_SERVER_URL=http://127.0.0.1:3737 -``` - -`MEMORY_BANK_SERVER_URL` is only needed if you are not using the default server address. - -## Machine-Wide Setup - -If you want the same Memory Bank setup in every OpenCode project on this machine: - -1. Put the MCP server in `~/.config/opencode/opencode.json`. -2. Copy or symlink the plugin into `~/.config/opencode/plugins/memory-bank.js`. -3. Export `MEMORY_BANK_HOOK_BIN` from your shell profile if the binary is not in the plugin's default project-relative location. - -Project-local `opencode.json` still overrides the global config when both exist. +- `~/.config/opencode/opencode.json` +- `~/.config/opencode/plugins/memory-bank.js` ## OpenCode Quirks -- Capture is plugin-based, not settings-hook based. +- Capture is plugin-based. MCP recall can work even if the plugin is not loading, so capture and recall can fail independently. - The plugin emits the final assistant fragment on `session.idle`, not from streaming assistant message updates. -- When `session.idle` fires, the plugin fetches the latest assistant message through the OpenCode session API. If that message is not durable yet, it retries briefly before giving up. -- If `memory-bank-hook` is missing, the plugin logs a warning and skips capture instead of crashing OpenCode. -- Debugging is available through: - -```bash -export MEMORY_BANK_OPENCODE_DEBUG=1 -export MEMORY_BANK_OPENCODE_DEBUG_FILE=/absolute/path/to/memory-bank-opencode.log -``` +- If the final assistant message is not durable yet when `session.idle` fires, the plugin retries briefly before giving up. +- If `memory-bank-hook` is missing, the plugin skips capture instead of crashing OpenCode. +- Optional debug logging is available through `MEMORY_BANK_OPENCODE_DEBUG=1` and `MEMORY_BANK_OPENCODE_DEBUG_FILE=/path/to/log`. ## Verify Useful checks: - `opencode mcp list` -- confirm `opencode.json` contains the `memory-bank` MCP entry -- confirm `.opencode/plugins/memory-bank.js` exists in the active project or global plugin directory - -Simple smoke test: - -```text -Remember that my favorite editor is Helix, then run pwd and summarize what you did. -``` +- confirm `~/.config/opencode/opencode.json` contains a `memory-bank` MCP entry +- confirm `~/.config/opencode/plugins/memory-bank.js` exists -Then ask: +Then restart OpenCode and run the same smoke test used in the main README. -```text -Before answering, use the memory-bank retrieve_memory tool to look up my editor preference and tell me what you find. -``` +If you need general fixes, use [Troubleshooting](./troubleshooting.md). For platform support and provider notes, see [Requirements](./requirements.md). diff --git a/docs/requirements.md b/docs/requirements.md new file mode 100644 index 0000000..e6e1553 --- /dev/null +++ b/docs/requirements.md @@ -0,0 +1,96 @@ +# Requirements + +## Supported Platforms + +Release installs are currently aimed at: + +- macOS on Apple Silicon +- Linux on `x86_64` +- Linux on `aarch64` / `arm64` + +Current caveats: + +- Intel macOS is source-build only for now: use `./install.sh --from-source` +- Windows is not currently a supported managed install target + +## What You Need + +- A supported agent installed if you want `mb setup` to wire it automatically: Claude Code, Gemini CLI, OpenCode, or OpenClaw +- One internal LLM provider for Memory Bank: Anthropic, Gemini, OpenAI, or Ollama +- Internet access for the default installer and for the first embedding-model download +- Enough local disk for `~/.memory_bank/`, the embedding model cache, and your namespace databases as they grow + +Memory Bank's default managed setup also expects: + +- `launchd` on macOS +- `systemd --user` on Linux + +## Local Resource Expectations + +Memory Bank is designed to run as a local background service on `127.0.0.1`. + +The default setup keeps the main moving parts on your machine: + +- the managed service +- the SQLite databases +- the FastEmbed model cache +- the agent integration assets copied into `~/.memory_bank/integrations/` + +The first startup can be slower than later ones because the default embedding model may need to download and initialize locally. + +There is no separate external database to provision for the default path. + +## Provider Billing + +Memory Bank itself is free to use. It does not charge you. + +What can cost money is the hosted provider you choose for Memory Bank's internal memory analysis. + +For example: + +- Claude Code plus Memory Bank on Anthropic means your Claude Code usage and your Anthropic usage can both matter, because they are separate systems +- OpenCode plus Memory Bank on Ollama avoids hosted LLM charges for Memory Bank's internal analysis, though OpenCode itself may still have its own separate billing depending on how you use it + +In the current design, each finalized turn usually triggers: + +- one memory-analysis LLM call +- sometimes a second graph-evolution LLM call when nearby existing memories are found +- local embeddings by default + +The default embedding path is local FastEmbed, so the default setup does not add a separate embedding API bill. + +## Token Caching And Provider Usage + +Memory Bank already takes advantage of provider-side prompt or token caching where supported: + +- Anthropic prompt caching is enabled +- OpenAI prompt caching is automatic on supported models +- Gemini relies on the provider's implicit caching on supported models + +If you want to keep hosted-provider usage lower: + +- choose a smaller or cheaper hosted model during `mb setup` +- use Ollama if you want Memory Bank's internal analysis to stay local +- leave advanced settings alone unless you have a specific reason to tune them + +If you need to review the currently saved provider and model: + +```bash +mb status +mb config show +``` + +## Practical Expectations + +For most users, the main questions are: + +- Can my machine run the local service? Usually yes if it can run the supported agent and a small local background process. +- Will the first run take longer? Yes, because of local model download and cache warm-up. +- Do I need a GPU? Not for the normal managed setup. +- Is Memory Bank itself paid software? No. +- Could a hosted provider bill me for Memory Bank's internal analysis? Yes, if you choose Anthropic, Gemini, or OpenAI instead of a local provider like Ollama. + +## Related Docs + +- [Troubleshooting](./troubleshooting.md) +- [Architecture](./architecture.md) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..be8a324 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,159 @@ +# Troubleshooting + +Start with the three commands below before changing anything else: + +```bash +mb status +mb doctor +mb logs -f +``` + +If you want Memory Bank to try safe repairs on its own, run: + +```bash +mb doctor --fix +``` + +## Setup Never Ran After Install + +This usually happens when the installer ran in a non-interactive shell. + +Run: + +```bash +mb setup +``` + +If `mb` is not available in the current shell yet, use: + +```bash +~/.memory_bank/bin/mb setup +``` + +Then open a new shell later so plain `mb` works there too. + +## Managed Service Is Not Active + +If `mb status` or `mb doctor` says the managed service is not active: + +1. Run `mb service status`. +2. Run `mb doctor --fix`. +3. If needed, run `mb service start`. +4. Follow the log with `mb logs -f`. + +On macOS the managed service uses `launchd`. On Linux it uses `systemd --user`. + +## Health Check To `/healthz` Failed + +This usually means the service is not fully up yet or the provider configuration is invalid. + +Check: + +- `mb logs -f` +- `mb status` +- `mb config show` + +Also remember that the first startup can take longer than later runs because the embedding model may need to download and warm its local cache under `~/.memory_bank/models/`. + +## Startup Is Slow After Changing The FastEmbed Model + +If you change `server.fastembed_model`, Memory Bank will rebuild the vector index and re-encode existing memories the next time the service starts for that namespace. + +That is expected. The CLI now asks you to confirm this change before saving it. + +Useful checks: + +- `mb config get server.fastembed_model` +- `mb status` +- `mb service restart` +- `mb logs -f` + +## Missing Provider Secret Or Invalid Provider Config + +Hosted providers need a saved secret so the background service can start on its own. + +The fastest fix is to rerun: + +```bash +mb setup +``` + +If you want to inspect the current saved settings first: + +```bash +mb config show +``` + +Memory Bank stores provider secrets in: + +```text +~/.memory_bank/secrets.env +``` + +## Ollama Is Not Working + +If you use Ollama and startup fails: + +- Make sure the Ollama daemon is actually running. +- Use the base Ollama URL, such as `http://localhost:11434`. +- Do not point Memory Bank at `/v1` or another path suffix. +- Make sure the selected model already exists locally. + +If the model is missing, pull it first: + +```bash +ollama pull +``` + +Then rerun `mb setup` or restart the service. + +## Recall Works But New Memories Are Not Showing Up + +That usually means MCP recall is configured, but the capture side is not loading correctly. + +Check: + +- `mb status` to see whether the integration is marked configured +- that you restarted the agent after running `mb setup` +- that you completed a full turn with a final assistant answer + +Agent-specific capture notes: + +- Claude Code and Gemini CLI need their Memory Bank hooks loaded. +- OpenCode capture is plugin-based. +- OpenClaw capture is extension-based and recall goes through the stdio proxy. + +If recall works but nothing new is being stored, the capture side is the first thing to inspect. + +## The Agent Never Calls `retrieve_memory` + +First confirm the MCP side is loaded: + +- Claude Code: use `/mcp` +- Gemini CLI: check `gemini mcp list` +- OpenCode: check `opencode mcp list` +- OpenClaw: check `openclaw mcp list` + +Then ask the agent explicitly to call `retrieve_memory` as part of a smoke test. Once the MCP integration is confirmed, you can decide how strongly you want to prompt the agent to use memory by default. + +## I Need To Inspect Or Undo Config Changes + +When `mb setup` rewrites JSON-based agent config files, it makes backups first. + +Look for: + +- sibling `*.mb_backup` files next to the edited config +- centralized backups under `~/.memory_bank/backups/` + +If something looks stale or inconsistent, rerun `mb setup` before hand-editing multiple files. + +## Still Stuck? + +Use the agent-specific guide for the integration you are using: + +- [Claude Code](./claude-code.md) +- [Gemini CLI](./gemini-cli.md) +- [OpenCode](./opencode.md) +- [OpenClaw](./openclaw.md) + +For a high-level overview of the moving parts, see [Architecture](./architecture.md). For platform support and provider notes, see [Requirements](./requirements.md). diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index 76608d4..6f3961b --- a/install.sh +++ b/install.sh @@ -5,6 +5,47 @@ REPO_OWNER="feelingsonice" REPO_NAME="MemoryBank" RELEASE_BASE_URL="https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/latest/download" +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +REPO_ROOT="${SCRIPT_DIR}" +APP_ROOT="${HOME}/.memory_bank" +BIN_DIR="${APP_ROOT}/bin" +CONFIG_DIR="${APP_ROOT}/config" +INTEGRATIONS_DIR="${APP_ROOT}/integrations" +OPENCODE_INSTALL_DIR="${INTEGRATIONS_DIR}/opencode" +OPENCLAW_INSTALL_DIR="${INTEGRATIONS_DIR}/openclaw" +FROM_SOURCE=0 + +usage() { + cat <<'EOF' +Usage: ./install.sh [--from-source] [--help] + +Options: + --from-source Build the workspace locally and install from this checkout + into ~/.memory_bank using the same layout as a GitHub Release. + --help Show this help text. +EOF +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --from-source) + FROM_SOURCE=1 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac + shift + done +} + detect_target() { os="$(uname -s)" arch="$(uname -m)" @@ -14,7 +55,7 @@ detect_target() { case "${arch}" in x86_64) echo "Intel macOS release binaries are not available yet because the current FastEmbed/ONNX Runtime dependency does not publish a supported x86_64-apple-darwin artifact." >&2 - echo "For now, please build from source on Intel macOS." >&2 + echo "For now, please build from source with ./install.sh --from-source on Intel macOS." >&2 exit 1 ;; arm64|aarch64) printf '%s\n' "aarch64-apple-darwin" ;; @@ -67,50 +108,145 @@ verify_checksum() { fi } -TARGET="$(detect_target)" -ARCHIVE_NAME="memory-bank-${TARGET}.tar.gz" -CHECKSUM_NAME="SHA256SUMS" +require_file() { + path="$1" + if [ ! -f "${path}" ]; then + echo "Required file is missing: ${path}" >&2 + exit 1 + fi +} + +copy_executable() { + source_path="$1" + target_path="$2" + require_file "${source_path}" + mkdir -p "$(dirname "${target_path}")" + cp "${source_path}" "${target_path}" + chmod 755 "${target_path}" +} -APP_ROOT="${HOME}/.memory_bank" -TMP_DIR="$(mktemp -d)" -ARCHIVE_PATH="${TMP_DIR}/${ARCHIVE_NAME}" -CHECKSUM_PATH="${TMP_DIR}/${CHECKSUM_NAME}" +install_source_assets() { + mkdir -p "${BIN_DIR}" "${CONFIG_DIR}" "${OPENCODE_INSTALL_DIR}" "${OPENCLAW_INSTALL_DIR}" + + copy_executable "${REPO_ROOT}/target/release/mb" "${BIN_DIR}/mb" + copy_executable "${REPO_ROOT}/target/release/memory-bank-server" "${BIN_DIR}/memory-bank-server" + copy_executable "${REPO_ROOT}/target/release/memory-bank-hook" "${BIN_DIR}/memory-bank-hook" + copy_executable "${REPO_ROOT}/target/release/memory-bank-mcp-proxy" "${BIN_DIR}/memory-bank-mcp-proxy" + + require_file "${REPO_ROOT}/config/setup-model-catalog.json" + cp "${REPO_ROOT}/config/setup-model-catalog.json" "${CONFIG_DIR}/setup-model-catalog.json" -cleanup() { - rm -rf "${TMP_DIR}" + require_file "${REPO_ROOT}/.opencode/plugins/memory-bank.js" + cp "${REPO_ROOT}/.opencode/plugins/memory-bank.js" "${OPENCODE_INSTALL_DIR}/memory-bank.js" + + if [ -d "${OPENCLAW_INSTALL_DIR}/memory-bank" ]; then + rm -rf "${OPENCLAW_INSTALL_DIR}/memory-bank" + fi + mkdir -p "${OPENCLAW_INSTALL_DIR}" + cp -R "${REPO_ROOT}/.openclaw/extensions/memory-bank" "${OPENCLAW_INSTALL_DIR}/memory-bank" } -trap cleanup EXIT INT TERM -mkdir -p "${APP_ROOT}" +install_from_source() { + require_file "${REPO_ROOT}/Cargo.toml" + require_file "${REPO_ROOT}/config/setup-model-catalog.json" + require_file "${REPO_ROOT}/.opencode/plugins/memory-bank.js" + require_file "${REPO_ROOT}/.openclaw/extensions/memory-bank/index.js" -curl -fsSL "${RELEASE_BASE_URL}/${ARCHIVE_NAME}" -o "${ARCHIVE_PATH}" -curl -fsSL "${RELEASE_BASE_URL}/${CHECKSUM_NAME}" -o "${CHECKSUM_PATH}" -verify_checksum "${ARCHIVE_NAME}" "${ARCHIVE_PATH}" "${CHECKSUM_PATH}" -tar -xzf "${ARCHIVE_PATH}" -C "${APP_ROOT}" + if ! command -v cargo >/dev/null 2>&1; then + echo "cargo is required for --from-source installs." >&2 + exit 1 + fi -BIN_DIR="${APP_ROOT}/bin" + echo "Building Memory Bank from source..." + cargo build --manifest-path "${REPO_ROOT}/Cargo.toml" --workspace --release + + echo "Installing locally built artifacts into ${APP_ROOT}..." + mkdir -p "${APP_ROOT}" + install_source_assets +} + +install_from_release() { + TARGET="$(detect_target)" + ARCHIVE_NAME="memory-bank-${TARGET}.tar.gz" + CHECKSUM_NAME="SHA256SUMS" + + TMP_DIR="$(mktemp -d)" + ARCHIVE_PATH="${TMP_DIR}/${ARCHIVE_NAME}" + CHECKSUM_PATH="${TMP_DIR}/${CHECKSUM_NAME}" + + cleanup() { + rm -rf "${TMP_DIR}" + } + trap cleanup EXIT INT TERM + + mkdir -p "${APP_ROOT}" + + curl -fsSL "${RELEASE_BASE_URL}/${ARCHIVE_NAME}" -o "${ARCHIVE_PATH}" + curl -fsSL "${RELEASE_BASE_URL}/${CHECKSUM_NAME}" -o "${CHECKSUM_PATH}" + verify_checksum "${ARCHIVE_NAME}" "${ARCHIVE_PATH}" "${CHECKSUM_PATH}" + tar -xzf "${ARCHIVE_PATH}" -C "${APP_ROOT}" +} + +bootstrap_install() { + "${BIN_DIR}/mb" internal bootstrap-install +} + +tty_available() { + [ -r /dev/tty ] && [ -w /dev/tty ] +} + +current_session_setup_command() { + if command -v mb >/dev/null 2>&1; then + printf '%s\n' "mb setup" + else + printf '%s\n' "${BIN_DIR}/mb setup" + fi +} + +print_deferred_setup_notice() { + SETUP_COMMAND="$(current_session_setup_command)" + echo "Skipping guided setup for now." + echo "Run \`${SETUP_COMMAND}\` to finish configuring Memory Bank." + if ! command -v mb >/dev/null 2>&1; then + echo "Bare \`mb\` will work in a new shell after your shell startup files reload." + fi +} + +run_setup() { + "${BIN_DIR}/mb" setup +} + +run_setup_via_tty() { + "${BIN_DIR}/mb" setup /dev/tty 2>&1 +} + +main() { + parse_args "$@" + + if [ "${FROM_SOURCE}" -eq 1 ]; then + install_from_source + else + install_from_release + fi + + bootstrap_install + + if [ "${NONINTERACTIVE:-0}" = "1" ]; then + print_deferred_setup_notice + return + fi + + if [ -t 0 ] && [ -t 1 ]; then + run_setup + return + fi + + if tty_available; then + run_setup_via_tty + return + fi + + print_deferred_setup_notice +} -case "${SHELL:-}" in - */zsh) - SHELL_RC="${HOME}/.zshrc" - ;; - */bash) - SHELL_RC="${HOME}/.bashrc" - ;; - *) - SHELL_RC="${HOME}/.profile" - ;; -esac - -if [ ! -f "${SHELL_RC}" ]; then - touch "${SHELL_RC}" -fi - -if ! grep -q '\.memory_bank/bin' "${SHELL_RC}"; then - { - printf '\n# Memory Bank\n' - printf 'export PATH="$HOME/.memory_bank/bin:$PATH"\n' - } >> "${SHELL_RC}" -fi - -"${BIN_DIR}/mb" setup +main "$@" diff --git a/memory-bank-app/Cargo.toml b/memory-bank-app/Cargo.toml index 63ae464..ee80deb 100644 --- a/memory-bank-app/Cargo.toml +++ b/memory-bank-app/Cargo.toml @@ -9,6 +9,7 @@ dirs.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true +toml.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/memory-bank-app/src/lib.rs b/memory-bank-app/src/lib.rs index 5f55761..bf1b8fd 100644 --- a/memory-bank-app/src/lib.rs +++ b/memory-bank-app/src/lib.rs @@ -4,18 +4,36 @@ use std::fmt; use std::fs; use std::path::{Path, PathBuf}; use std::str::FromStr; +use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; pub const APP_DIR_NAME: &str = ".memory_bank"; pub const DEFAULT_NAMESPACE_NAME: &str = "default"; pub const DEFAULT_PORT: u16 = 3737; pub const SETTINGS_SCHEMA_VERSION: u32 = 1; -pub const DEFAULT_GEMINI_MODEL: &str = "gemini-2.5-flash"; +pub const SETTINGS_FILE_NAME: &str = "settings.toml"; +pub const DEFAULT_GEMINI_MODEL: &str = "gemini-3-flash-preview"; pub const DEFAULT_ANTHROPIC_MODEL: &str = "claude-sonnet-4-6"; -pub const DEFAULT_OPENAI_MODEL: &str = "gpt-4o-mini"; +pub const DEFAULT_OPENAI_MODEL: &str = "gpt-5-mini"; pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434"; -pub const DEFAULT_OLLAMA_MODEL: &str = "qwen3:4b"; +pub const DEFAULT_OLLAMA_MODEL: &str = "qwen3"; pub const DEFAULT_FASTEMBED_MODEL: &str = "jinaai/jina-embeddings-v2-base-code"; +pub const SERVER_STARTUP_STATE_FILE_NAME: &str = "server-startup.json"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ServerStartupPhase { + Reindexing, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ServerStartupState { + pub pid: u32, + pub namespace: String, + pub phase: ServerStartupPhase, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory_count: Option, +} #[derive(Debug, Error)] pub enum AppConfigError { @@ -25,6 +43,10 @@ pub enum AppConfigError { Io(#[from] std::io::Error), #[error("JSON error: {0}")] Json(#[from] serde_json::Error), + #[error("TOML decode error: {0}")] + TomlDecode(#[from] toml::de::Error), + #[error("TOML encode error: {0}")] + TomlEncode(#[from] toml::ser::Error), #[error("unsupported settings schema version {0}")] UnsupportedSchemaVersion(u32), } @@ -38,6 +60,7 @@ impl Namespace { } pub fn sanitize(raw: &str) -> String { + let raw = raw.trim(); let sanitized: String = raw .chars() .map(|c| { @@ -88,12 +111,14 @@ pub struct AppPaths { pub home_dir: PathBuf, pub root: PathBuf, pub bin_dir: PathBuf, + pub config_dir: PathBuf, pub logs_dir: PathBuf, pub namespaces_dir: PathBuf, pub integrations_dir: PathBuf, pub backups_dir: PathBuf, pub settings_file: PathBuf, pub secrets_file: PathBuf, + pub model_catalog_file: PathBuf, pub log_file: PathBuf, } @@ -106,17 +131,20 @@ impl AppPaths { pub fn from_home_dir(home_dir: PathBuf) -> Self { let root = home_dir.join(APP_DIR_NAME); let bin_dir = root.join("bin"); + let config_dir = root.join("config"); let logs_dir = root.join("logs"); let namespaces_dir = root.join("namespaces"); let integrations_dir = root.join("integrations"); let backups_dir = root.join("backups"); Self { home_dir, - settings_file: root.join("settings.json"), + settings_file: root.join(SETTINGS_FILE_NAME), secrets_file: root.join("secrets.env"), + model_catalog_file: config_dir.join("setup-model-catalog.json"), log_file: logs_dir.join("server.log"), root, bin_dir, + config_dir, logs_dir, namespaces_dir, integrations_dir, @@ -128,6 +156,7 @@ impl AppPaths { for path in [ &self.root, &self.bin_dir, + &self.config_dir, &self.logs_dir, &self.namespaces_dir, &self.integrations_dir, @@ -152,6 +181,11 @@ impl AppPaths { self.namespace_dir(namespace).join("memory.db") } + pub fn server_startup_state_path(&self, namespace: &Namespace) -> PathBuf { + self.namespace_dir(namespace) + .join(SERVER_STARTUP_STATE_FILE_NAME) + } + pub fn models_dir(&self) -> PathBuf { self.root.join("models") } @@ -163,6 +197,7 @@ impl AppPaths { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AppSettings { + #[serde(default = "default_settings_schema_version")] pub schema_version: u32, #[serde(default, skip_serializing_if = "Option::is_none")] pub active_namespace: Option, @@ -177,7 +212,7 @@ pub struct AppSettings { impl Default for AppSettings { fn default() -> Self { Self { - schema_version: SETTINGS_SCHEMA_VERSION, + schema_version: default_settings_schema_version(), active_namespace: None, service: None, server: None, @@ -186,30 +221,30 @@ impl Default for AppSettings { } } +fn default_settings_schema_version() -> u32 { + SETTINGS_SCHEMA_VERSION +} + impl AppSettings { pub fn load(paths: &AppPaths) -> Result { - if !paths.settings_file.exists() { - return Ok(Self::default()); + if paths.settings_file.exists() { + return load_settings_from_toml(&paths.settings_file); } - let contents = fs::read_to_string(&paths.settings_file)?; - let settings: Self = serde_json::from_str(&contents)?; - if settings.schema_version != SETTINGS_SCHEMA_VERSION { - return Err(AppConfigError::UnsupportedSchemaVersion( - settings.schema_version, - )); - } - - Ok(settings) + Ok(Self::default()) } pub fn save(&self, paths: &AppPaths) -> Result<(), AppConfigError> { paths.ensure_base_dirs()?; - let contents = serde_json::to_string_pretty(self)?; - fs::write(&paths.settings_file, format!("{contents}\n"))?; + let contents = toml::to_string_pretty(self)?; + write_text_file(&paths.settings_file, &format!("{contents}\n"))?; Ok(()) } + pub fn to_toml_string(&self) -> Result { + Ok(toml::to_string_pretty(self)?) + } + pub fn active_namespace(&self) -> Namespace { self.active_namespace .as_deref() @@ -232,6 +267,27 @@ impl AppSettings { } } +fn load_settings_from_toml(path: &Path) -> Result { + let contents = read_text_file(path)?; + if contents.trim().is_empty() { + return Ok(AppSettings::default()); + } + + let settings: AppSettings = toml::from_str(&contents)?; + validate_schema_version(&settings)?; + Ok(settings) +} + +fn validate_schema_version(settings: &AppSettings) -> Result<(), AppConfigError> { + if settings.schema_version != SETTINGS_SCHEMA_VERSION { + return Err(AppConfigError::UnsupportedSchemaVersion( + settings.schema_version, + )); + } + + Ok(()) +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct ServiceSettings { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -253,6 +309,8 @@ pub struct ServerSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub llm_model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub ollama_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub encoder_provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub fastembed_model: Option, @@ -270,6 +328,7 @@ impl ServerSettings { pub fn is_empty(&self) -> bool { self.llm_provider.is_none() && self.llm_model.is_none() + && self.ollama_url.is_none() && self.encoder_provider.is_none() && self.fastembed_model.is_none() && self.history_window_size.is_none() @@ -316,7 +375,7 @@ impl SecretStore { return Ok(Self::default()); } - let contents = fs::read_to_string(&paths.secrets_file)?; + let contents = read_text_file(&paths.secrets_file)?; Ok(Self::parse(&contents)) } @@ -330,11 +389,12 @@ impl SecretStore { if !rendered.is_empty() { rendered.push('\n'); } - fs::write(&paths.secrets_file, rendered)?; + write_text_file(&paths.secrets_file, &rendered)?; Ok(()) } pub fn parse(contents: &str) -> Self { + let contents = strip_utf8_bom(contents); let mut values = BTreeMap::new(); for raw_line in contents.lines() { let line = raw_line.trim(); @@ -346,7 +406,7 @@ impl SecretStore { continue; }; - let key = key.trim(); + let key = normalize_env_key(key); if key.is_empty() { continue; } @@ -385,19 +445,13 @@ pub fn env_key_for_provider(provider: &str) -> Option<&'static str> { } pub fn write_json_file(path: &Path, value: &T) -> Result<(), AppConfigError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } let contents = serde_json::to_string_pretty(value)?; - fs::write(path, format!("{contents}\n"))?; + write_text_file(path, &format!("{contents}\n"))?; Ok(()) } fn escape_env_value(value: &str) -> String { - if value - .chars() - .any(|c| c.is_whitespace() || c == '"' || c == '\'') - { + if value.is_empty() || value.chars().any(|c| !is_shell_safe_env_char(c)) { format!("{:?}", value) } else { value.to_string() @@ -405,20 +459,115 @@ fn escape_env_value(value: &str) -> String { } fn unescape_env_value(value: &str) -> String { - if (value.starts_with('"') && value.ends_with('"')) - || (value.starts_with('\'') && value.ends_with('\'')) - { - value[1..value.len() - 1].to_string() - } else { - value.to_string() + let value = value.trim(); + if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { + return serde_json::from_str::(value) + .unwrap_or_else(|_| value[1..value.len() - 1].to_string()); + } + + if value.len() >= 2 && value.starts_with('\'') && value.ends_with('\'') { + return value[1..value.len() - 1] + .replace("\\'", "'") + .replace("\\\\", "\\"); + } + + value.to_string() +} + +fn normalize_env_key(key: &str) -> &str { + key.trim() + .strip_prefix("export ") + .map(str::trim) + .unwrap_or_else(|| key.trim()) +} + +fn is_shell_safe_env_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | ':' | '+' | '=' | '@') +} + +fn strip_utf8_bom(contents: &str) -> &str { + contents.strip_prefix('\u{feff}').unwrap_or(contents) +} + +fn read_text_file(path: &Path) -> Result { + let contents = fs::read_to_string(path)?; + Ok(strip_utf8_bom(&contents).to_string()) +} + +fn write_text_file(path: &Path, contents: &str) -> Result<(), std::io::Error> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let temp_path = temp_write_path(path); + fs::write(&temp_path, contents)?; + if let Ok(metadata) = fs::metadata(path) { + fs::set_permissions(&temp_path, metadata.permissions())?; + } + + #[cfg(windows)] + if path.exists() { + fs::remove_file(path)?; + } + + match fs::rename(&temp_path, path) { + Ok(()) => Ok(()), + Err(error) => { + let _ = fs::remove_file(&temp_path); + Err(error) + } } } +fn temp_write_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("memory-bank"); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + path.with_file_name(format!( + ".{file_name}.tmp-{}-{timestamp}", + std::process::id() + )) +} + #[cfg(test)] mod tests { use super::*; + use serde_json::json; use tempfile::TempDir; + #[test] + fn app_paths_create_base_dirs_and_namespace_helpers() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + + paths.ensure_base_dirs().expect("base dirs"); + let namespace = Namespace::new("team a/1"); + let namespace_dir = paths + .ensure_namespace_dir(&namespace) + .expect("namespace dir"); + + for dir in [ + &paths.root, + &paths.bin_dir, + &paths.config_dir, + &paths.logs_dir, + &paths.namespaces_dir, + &paths.integrations_dir, + &paths.backups_dir, + ] { + assert!(dir.is_dir(), "expected {} to exist", dir.display()); + } + assert_eq!(namespace_dir, paths.namespace_dir(&namespace)); + assert!(namespace_dir.is_dir()); + assert_eq!(paths.db_path(&namespace), namespace_dir.join("memory.db")); + assert_eq!(paths.binary_path("mb"), paths.bin_dir.join("mb")); + } + #[test] fn settings_load_defaults_when_file_is_missing() { let temp = TempDir::new().expect("tempdir"); @@ -426,11 +575,30 @@ mod tests { let settings = AppSettings::load(&paths).expect("load settings"); + assert_eq!( + paths + .settings_file + .file_name() + .and_then(|name| name.to_str()), + Some(SETTINGS_FILE_NAME) + ); assert_eq!(settings.schema_version, SETTINGS_SCHEMA_VERSION); assert_eq!(settings.active_namespace(), Namespace::default()); assert_eq!(settings.resolved_port(), DEFAULT_PORT); } + #[test] + fn settings_load_defaults_from_comment_only_toml() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + paths.ensure_base_dirs().expect("base dirs"); + fs::write(&paths.settings_file, "# user comments only\n").expect("write settings"); + + let settings = AppSettings::load(&paths).expect("load settings"); + + assert_eq!(settings, AppSettings::default()); + } + #[test] fn settings_round_trip_sparse_fields() { let temp = TempDir::new().expect("tempdir"); @@ -444,7 +612,7 @@ mod tests { }), server: Some(ServerSettings { llm_provider: Some("anthropic".to_string()), - llm_model: Some("claude-sonnet-4-6".to_string()), + llm_model: Some(DEFAULT_ANTHROPIC_MODEL.to_string()), ..ServerSettings::default() }), integrations: None, @@ -455,9 +623,91 @@ mod tests { assert_eq!(reloaded, settings); let raw = fs::read_to_string(paths.settings_file).expect("settings file"); + assert!(raw.contains("schema_version = 1")); + assert!(raw.contains("active_namespace = \"work\"")); + assert!(raw.contains("[service]")); + assert!(raw.contains("[server]")); assert!(!raw.contains("null")); } + #[test] + fn settings_save_only_writes_toml_settings_file() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + + AppSettings::default().save(&paths).expect("save settings"); + + assert!(paths.settings_file.exists()); + assert!(!paths.root.join("settings.json").exists()); + } + + #[test] + fn settings_render_toml_string_matches_saved_format() { + let settings = AppSettings { + schema_version: SETTINGS_SCHEMA_VERSION, + active_namespace: Some("work".to_string()), + service: Some(ServiceSettings { + port: Some(4444), + autostart: Some(true), + }), + ..AppSettings::default() + }; + + let rendered = settings.to_toml_string().expect("render toml"); + + assert!(rendered.contains("schema_version = 1")); + assert!(rendered.contains("[service]")); + assert!(rendered.contains("port = 4444")); + assert!(rendered.contains("autostart = true")); + } + + #[test] + fn settings_load_defaults_schema_version_when_missing() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + paths.ensure_base_dirs().expect("base dirs"); + fs::write( + &paths.settings_file, + r#" +active_namespace = "work" + +[service] +port = 4444 +"#, + ) + .expect("write settings"); + + let settings = AppSettings::load(&paths).expect("load settings"); + + assert_eq!(settings.schema_version, SETTINGS_SCHEMA_VERSION); + assert_eq!(settings.active_namespace.as_deref(), Some("work")); + assert_eq!( + settings.service.and_then(|service| service.port), + Some(4444) + ); + } + + #[test] + fn settings_reject_unsupported_schema_versions() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + paths.ensure_base_dirs().expect("base dirs"); + fs::write( + &paths.settings_file, + r#" +schema_version = 99 +"#, + ) + .expect("write settings"); + + let error = AppSettings::load(&paths).expect_err("unsupported schema version"); + + assert!(matches!( + error, + AppConfigError::UnsupportedSchemaVersion(99) + )); + } + #[test] fn secret_store_parses_basic_env_lines() { let secrets = SecretStore::parse( @@ -472,9 +722,117 @@ OPENAI_API_KEY="quoted value" assert_eq!(secrets.get("OPENAI_API_KEY"), Some("quoted value")); } + #[test] + fn secret_store_parses_exported_and_escaped_values() { + let secrets = SecretStore::parse( + r#" +export OPENAI_API_KEY="quoted \"value\"" +export GEMINI_API_KEY='single quoted value' +"#, + ); + + assert_eq!(secrets.get("OPENAI_API_KEY"), Some(r#"quoted "value""#)); + assert_eq!(secrets.get("GEMINI_API_KEY"), Some("single quoted value")); + } + + #[test] + fn secret_store_round_trips_escaped_values() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let mut secrets = SecretStore::default(); + secrets.set("OPENAI_API_KEY", "line one\nline \"two\""); + secrets.set("GEMINI_API_KEY", r#"C:\Users\me\key"#); + + secrets.save(&paths).expect("save secrets"); + let reloaded = SecretStore::load(&paths).expect("reload secrets"); + + assert_eq!( + reloaded.get("OPENAI_API_KEY"), + Some("line one\nline \"two\"") + ); + assert_eq!(reloaded.get("GEMINI_API_KEY"), Some(r#"C:\Users\me\key"#)); + } + + #[test] + fn secret_store_quotes_shell_sensitive_values_when_saving() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let mut secrets = SecretStore::default(); + secrets.set("OPENAI_API_KEY", "value with $dollar and #hash"); + + secrets.save(&paths).expect("save secrets"); + let raw = fs::read_to_string(&paths.secrets_file).expect("secrets file"); + + assert!(raw.contains(r#"OPENAI_API_KEY="value with $dollar and #hash""#)); + let reloaded = SecretStore::load(&paths).expect("reload secrets"); + assert_eq!( + reloaded.get("OPENAI_API_KEY"), + Some("value with $dollar and #hash") + ); + } + + #[test] + fn secret_store_parses_bom_invalid_lines_and_empty_values() { + let secrets = SecretStore::parse( + "\u{feff}export OPENAI_API_KEY=abc123\nINVALID\n =oops\nEMPTY=\nSINGLE='C:\\\\Users\\\\me'\n", + ); + + assert_eq!(secrets.get("OPENAI_API_KEY"), Some("abc123")); + assert_eq!(secrets.get("EMPTY"), Some("")); + assert_eq!(secrets.get("SINGLE"), Some(r#"C:\Users\me"#)); + assert_eq!(secrets.get("INVALID"), None); + } + + #[test] + fn secret_store_save_sorts_keys_and_ends_with_newline() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let mut secrets = SecretStore::default(); + secrets.set("Z_KEY", "last"); + secrets.set("A_KEY", "first"); + + secrets.save(&paths).expect("save secrets"); + let raw = fs::read_to_string(&paths.secrets_file).expect("secrets file"); + + let lines: Vec<_> = raw.lines().collect(); + assert_eq!(lines, vec!["A_KEY=first", "Z_KEY=last"]); + assert!(raw.ends_with('\n')); + } + + #[test] + fn write_json_file_creates_parent_directories_and_formats_output() { + let temp = TempDir::new().expect("tempdir"); + let path = temp.path().join("nested/config.json"); + + write_json_file(&path, &json!({ "ok": true, "count": 2 })).expect("write json"); + + let raw = fs::read_to_string(&path).expect("config file"); + assert!(path.exists()); + assert!(raw.ends_with('\n')); + assert_eq!( + serde_json::from_str::(&raw).expect("parse json"), + json!({ "ok": true, "count": 2 }) + ); + } + + #[test] + fn env_key_for_provider_maps_supported_values() { + assert_eq!(env_key_for_provider("anthropic"), Some("ANTHROPIC_API_KEY")); + assert_eq!(env_key_for_provider("gemini"), Some("GEMINI_API_KEY")); + assert_eq!(env_key_for_provider("open-ai"), Some("OPENAI_API_KEY")); + assert_eq!(env_key_for_provider("ollama"), None); + assert_eq!(env_key_for_provider("unknown"), None); + } + #[test] fn namespace_sanitizes_invalid_characters() { let namespace = Namespace::new("team a/1"); assert_eq!(namespace.as_ref(), "team_a_1"); } + + #[test] + fn namespace_whitespace_falls_back_to_default() { + let namespace = Namespace::new(" "); + assert_eq!(namespace, Namespace::default()); + } } diff --git a/memory-bank-cli/Cargo.toml b/memory-bank-cli/Cargo.toml index d798043..c9867e2 100644 --- a/memory-bank-cli/Cargo.toml +++ b/memory-bank-cli/Cargo.toml @@ -11,12 +11,16 @@ path = "src/main.rs" [dependencies] chrono.workspace = true clap.workspace = true -dialoguer.workspace = true +inquire.workspace = true +jsonc-parser.workspace = true memory-bank-app = { path = "../memory-bank-app" } serde.workspace = true serde_json.workspace = true +shlex.workspace = true thiserror.workspace = true ureq.workspace = true +which.workspace = true [dev-dependencies] tempfile.workspace = true +wait-timeout = "0.2" diff --git a/memory-bank-cli/src/agents.rs b/memory-bank-cli/src/agents.rs new file mode 100644 index 0000000..58f001e --- /dev/null +++ b/memory-bank-cli/src/agents.rs @@ -0,0 +1,675 @@ +mod claude; +mod gemini; +mod openclaw; +mod opencode; +mod shared; + +use crate::AppError; +use crate::assets::find_on_path; +#[cfg(test)] +use crate::command_utils::CommandOutcome; +#[cfg(test)] +use crate::command_utils::CommandRunOptions; +use crate::domain::integration_configured as integration_configured_for_settings; +use memory_bank_app::{AppPaths, AppSettings, default_server_url}; +#[cfg(test)] +use serde_json::{Map, Value, json}; +use std::fmt; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AgentKind { + ClaudeCode, + GeminiCli, + OpenCode, + OpenClaw, +} + +#[derive(Debug)] +pub(crate) struct AgentSetupOutcome { + pub(crate) configured: Vec, + pub(crate) warnings: Vec, +} + +impl AgentKind { + pub(crate) fn all() -> [Self; 4] { + [ + Self::ClaudeCode, + Self::GeminiCli, + Self::OpenCode, + Self::OpenClaw, + ] + } + + pub(crate) fn command_name(self) -> &'static str { + match self { + Self::ClaudeCode => "claude", + Self::GeminiCli => "gemini", + Self::OpenCode => "opencode", + Self::OpenClaw => "openclaw", + } + } + + pub(crate) fn display_name(self) -> &'static str { + match self { + Self::ClaudeCode => "Claude Code", + Self::GeminiCli => "Gemini CLI", + Self::OpenCode => "OpenCode", + Self::OpenClaw => "OpenClaw", + } + } +} + +impl fmt::Display for AgentKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.display_name()) + } +} + +pub(crate) fn detect_installed_agents() -> Vec { + AgentKind::all() + .into_iter() + .filter(|agent| find_on_path(agent.command_name()).is_some()) + .collect() +} + +pub(crate) fn configure_selected_agents( + paths: &AppPaths, + settings: &AppSettings, + selected_agents: &[AgentKind], +) -> Result { + let server_url = default_server_url(settings); + let mut configured = Vec::new(); + let mut warnings = Vec::new(); + + for agent in selected_agents { + let result = match agent { + AgentKind::ClaudeCode => configure_claude(paths, &server_url), + AgentKind::GeminiCli => configure_gemini(paths, &server_url), + AgentKind::OpenCode => configure_opencode(paths, &server_url), + AgentKind::OpenClaw => configure_openclaw(paths, &server_url), + }; + match result { + Ok(()) => configured.push(*agent), + Err(error) => warnings.push(format!("{}: {}", agent.display_name(), error)), + } + } + + Ok(AgentSetupOutcome { + configured, + warnings, + }) +} + +pub(crate) fn integration_configured(settings: &AppSettings, agent: AgentKind) -> bool { + integration_configured_for_settings(settings.integrations.as_ref(), agent) +} + +fn configure_claude(paths: &AppPaths, server_url: &str) -> Result<(), AppError> { + claude::configure(paths, server_url) +} + +#[cfg(test)] +fn configure_claude_with_options( + paths: &AppPaths, + server_url: &str, + options: &CommandRunOptions, +) -> Result<(), AppError> { + claude::configure_with_options(paths, server_url, options) +} + +fn configure_gemini(paths: &AppPaths, server_url: &str) -> Result<(), AppError> { + gemini::configure(paths, server_url) +} + +fn configure_opencode(paths: &AppPaths, server_url: &str) -> Result<(), AppError> { + opencode::configure(paths, server_url) +} + +fn configure_openclaw(paths: &AppPaths, server_url: &str) -> Result<(), AppError> { + openclaw::configure(paths, server_url) +} + +#[cfg(test)] +fn ensure_claude_user_mcp_with_runner(server_url: &str, mut run: F) -> Result<(), AppError> +where + F: FnMut(&[&str]) -> Result, +{ + claude::ensure_user_mcp_with_runner(server_url, &mut run) +} + +#[cfg(test)] +fn claude_mcp_matches(outcome: &CommandOutcome, desired_url: &str) -> bool { + claude::mcp_matches(outcome, desired_url) +} + +#[cfg(test)] +fn build_hook_command( + hook_binary: &std::path::Path, + agent: &str, + event: &str, + server_url: &str, +) -> String { + shared::build_hook_command(hook_binary, agent, event, server_url) +} + +#[cfg(test)] +fn upsert_claude_hook(root: &mut Value, event: &str, command: &str) -> Result<(), AppError> { + claude::upsert_hook(root, event, command) +} + +#[cfg(test)] +fn upsert_gemini_hook( + root: &mut Value, + event: &str, + matcher: &str, + command: &str, +) -> Result<(), AppError> { + gemini::upsert_hook(root, event, matcher, command) +} + +#[cfg(test)] +fn upsert_openclaw_plugin_load_path( + load_map: &mut Map, + desired_path: &str, +) -> Result<(), AppError> { + openclaw::upsert_plugin_load_path(load_map, desired_path) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::MCP_PROXY_BINARY_NAME; + use crate::json_config::load_json_config; + use crate::real_binary_tests::RealBinaryTestEnv; + use std::cell::RefCell; + use std::collections::VecDeque; + use std::fs; + use tempfile::TempDir; + + #[test] + fn claude_mcp_matches_expected_user_http_server() { + let outcome = CommandOutcome { + program: "claude".to_string(), + args: vec!["mcp".to_string(), "get".to_string(), "memory-bank".to_string()], + exit_code: Some(0), + success: true, + stdout: "memory-bank:\n Scope: User config (available in all your projects)\n Status: ✗ Failed to connect\n Type: http\n URL: http://127.0.0.1:3737/mcp\n".to_string(), + stderr: String::new(), + }; + + assert!(claude_mcp_matches(&outcome, "http://127.0.0.1:3737/mcp")); + } + + #[test] + fn upsert_openclaw_plugin_load_path_replaces_stale_memory_bank_paths() { + let mut load_map = Map::new(); + load_map.insert( + "paths".to_string(), + json!([ + "/tmp/something-else", + "/old/repo/.openclaw/extensions/memory-bank" + ]), + ); + + upsert_openclaw_plugin_load_path( + &mut load_map, + "/Users/test/.memory_bank/integrations/openclaw/memory-bank", + ) + .expect("upsert load path"); + + assert_eq!( + load_map.get("paths").expect("paths"), + &json!([ + "/tmp/something-else", + "/Users/test/.memory_bank/integrations/openclaw/memory-bank" + ]) + ); + } + + #[test] + fn upsert_gemini_hook_refreshes_matcher_and_command() { + let mut root = json!({ + "hooks": { + "BeforeTool": [ + { + "matcher": "stale", + "sequential": false, + "hooks": [ + { + "name": "memory-bank", + "type": "command", + "command": "old-command" + } + ] + } + ] + } + }); + + upsert_gemini_hook(&mut root, "BeforeTool", ".*", "new-command") + .expect("upsert gemini hook"); + + let group = &root["hooks"]["BeforeTool"][0]; + assert_eq!(group["matcher"], Value::String(".*".to_string())); + assert_eq!(group["sequential"], Value::Bool(true)); + assert_eq!( + group["hooks"][0]["command"], + Value::String("new-command".to_string()) + ); + } + + #[test] + fn build_hook_command_shell_escapes_paths_with_spaces() { + let command = build_hook_command( + std::path::Path::new("/Users/test/Memory Bank/bin/memory-bank-hook"), + "gemini-cli", + "BeforeAgent", + "http://127.0.0.1:3737", + ); + + assert_eq!( + command, + "'/Users/test/Memory Bank/bin/memory-bank-hook' --agent 'gemini-cli' --event 'BeforeAgent' --server-url 'http://127.0.0.1:3737'" + ); + } + + #[test] + fn upsert_claude_hook_replaces_existing_memory_bank_command() { + let mut root = json!({ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "old --agent claude-code --event Stop" + } + ] + } + ] + } + }); + + upsert_claude_hook(&mut root, "Stop", "new-command").expect("upsert claude hook"); + + assert_eq!( + root["hooks"]["Stop"][0]["hooks"][0]["command"], + Value::String("new-command".to_string()) + ); + } + + #[test] + fn configure_gemini_writes_mcp_hooks_and_backup() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings_path = paths.home_dir.join(".gemini/settings.json"); + fs::create_dir_all(settings_path.parent().expect("parent")).expect("parent"); + fs::write(&settings_path, r#"{ "existing": true }"#).expect("existing config"); + + configure_gemini(&paths, "http://127.0.0.1:4545").expect("configure gemini"); + + let rendered = load_json_config(&settings_path).expect("load gemini config"); + assert_eq!( + rendered["mcpServers"]["memory-bank"]["httpUrl"], + Value::String("http://127.0.0.1:4545/mcp".to_string()) + ); + assert_eq!( + rendered["hooks"]["BeforeTool"][0]["hooks"][0]["name"], + Value::String("memory-bank".to_string()) + ); + assert!(settings_path.with_extension("json.mb_backup").exists()); + } + + #[test] + fn configure_gemini_repairs_malformed_sections() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings_path = paths.home_dir.join(".gemini/settings.json"); + fs::create_dir_all(settings_path.parent().expect("parent")).expect("parent"); + fs::write( + &settings_path, + r#"{ + "mcpServers": [], + "hooks": { + "BeforeTool": {} + } +}"#, + ) + .expect("seed config"); + + configure_gemini(&paths, "http://127.0.0.1:4545").expect("configure gemini"); + + let rendered = load_json_config(&settings_path).expect("load gemini config"); + assert_eq!( + rendered["mcpServers"]["memory-bank"]["httpUrl"], + Value::String("http://127.0.0.1:4545/mcp".to_string()) + ); + assert_eq!( + rendered["hooks"]["BeforeTool"][0]["hooks"][0]["name"], + Value::String("memory-bank".to_string()) + ); + } + + #[test] + fn configure_opencode_copies_plugin_and_sets_remote_mcp_entry() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let plugin_source = paths.integrations_dir.join("opencode/memory-bank.js"); + fs::create_dir_all(plugin_source.parent().expect("parent")).expect("parent"); + fs::write(&plugin_source, "// plugin").expect("plugin source"); + + configure_opencode(&paths, "http://127.0.0.1:3737").expect("configure opencode"); + + let plugin_target = paths + .home_dir + .join(".config/opencode/plugins/memory-bank.js"); + assert_eq!( + fs::read_to_string(plugin_target).expect("plugin"), + "// plugin" + ); + + let settings = load_json_config(&paths.home_dir.join(".config/opencode/opencode.json")) + .expect("load opencode config"); + assert_eq!( + settings["mcp"]["memory-bank"]["url"], + Value::String("http://127.0.0.1:3737/mcp".to_string()) + ); + assert_eq!(settings["mcp"]["memory-bank"]["enabled"], Value::Bool(true)); + } + + #[test] + fn configure_openclaw_rewrites_plugin_load_paths_and_proxy_config() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings_path = paths.home_dir.join(".openclaw/openclaw.json"); + fs::create_dir_all(settings_path.parent().expect("parent")).expect("parent"); + fs::write( + &settings_path, + json!({ + "plugins": { + "load": { + "paths": [ + "/tmp/other-plugin", + "/old/repo/.openclaw/extensions/memory-bank" + ] + } + } + }) + .to_string(), + ) + .expect("seed config"); + + configure_openclaw(&paths, "http://127.0.0.1:6000").expect("configure openclaw"); + + let rendered = load_json_config(&settings_path).expect("load openclaw config"); + assert_eq!( + rendered["mcp"]["servers"]["memory-bank"]["command"], + Value::String( + paths + .binary_path(MCP_PROXY_BINARY_NAME) + .to_string_lossy() + .to_string() + ) + ); + assert_eq!( + rendered["plugins"]["entries"]["memory-bank"]["config"]["serverUrl"], + Value::String("http://127.0.0.1:6000".to_string()) + ); + assert_eq!( + rendered["plugins"]["load"]["paths"], + json!([ + "/tmp/other-plugin", + paths + .integrations_dir + .join("openclaw/memory-bank") + .to_string_lossy() + .to_string() + ]) + ); + } + + #[test] + fn configure_openclaw_repairs_malformed_sections() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings_path = paths.home_dir.join(".openclaw/openclaw.json"); + fs::create_dir_all(settings_path.parent().expect("parent")).expect("parent"); + fs::write( + &settings_path, + r#"{ + "mcp": [], + "plugins": { + "load": [], + "entries": [], + "slots": [] + } +}"#, + ) + .expect("seed config"); + + configure_openclaw(&paths, "http://127.0.0.1:6000").expect("configure openclaw"); + + let rendered = load_json_config(&settings_path).expect("load openclaw config"); + assert_eq!( + rendered["mcp"]["servers"]["memory-bank"]["args"], + json!(["--server-url", "http://127.0.0.1:6000"]) + ); + assert_eq!( + rendered["plugins"]["slots"]["memory"], + Value::String("none".to_string()) + ); + } + + #[test] + fn configure_gemini_is_idempotent_across_reruns() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + + configure_gemini(&paths, "http://127.0.0.1:3737").expect("first configure"); + configure_gemini(&paths, "http://127.0.0.1:3737").expect("second configure"); + + let rendered = + fs::read_to_string(paths.home_dir.join(".gemini/settings.json")).expect("settings"); + assert_eq!( + rendered + .matches("\"httpUrl\": \"http://127.0.0.1:3737/mcp\"") + .count(), + 1 + ); + assert_eq!(rendered.matches("\"name\": \"memory-bank\"").count(), 4); + } + + #[test] + fn configure_opencode_is_idempotent_across_reruns() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let plugin_source = paths.integrations_dir.join("opencode/memory-bank.js"); + fs::create_dir_all(plugin_source.parent().expect("parent")).expect("parent"); + fs::write(&plugin_source, "// plugin").expect("plugin source"); + + configure_opencode(&paths, "http://127.0.0.1:3737").expect("first configure"); + configure_opencode(&paths, "http://127.0.0.1:3737").expect("second configure"); + + let rendered = fs::read_to_string(paths.home_dir.join(".config/opencode/opencode.json")) + .expect("settings"); + assert_eq!(rendered.matches("\"memory-bank\"").count(), 1); + assert_eq!(rendered.matches("http://127.0.0.1:3737/mcp").count(), 1); + } + + #[test] + fn configure_openclaw_is_idempotent_across_reruns() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + + configure_openclaw(&paths, "http://127.0.0.1:3737").expect("first configure"); + configure_openclaw(&paths, "http://127.0.0.1:3737").expect("second configure"); + + let rendered = + fs::read_to_string(paths.home_dir.join(".openclaw/openclaw.json")).expect("settings"); + let load_path = paths + .integrations_dir + .join("openclaw/memory-bank") + .to_string_lossy() + .to_string(); + assert_eq!(rendered.matches(&load_path).count(), 1); + assert_eq!(rendered.matches("\"memory-bank\"").count(), 2); + } + + #[test] + fn ensure_claude_user_mcp_rejects_conflicting_project_scope() { + let error = ensure_claude_user_mcp_with_runner("http://127.0.0.1:3737", |_| { + Ok(CommandOutcome { + program: "claude".to_string(), + args: vec!["mcp".to_string(), "get".to_string(), "memory-bank".to_string()], + exit_code: Some(0), + success: true, + stdout: "memory-bank:\n Scope: Project config\n Type: http\n URL: http://127.0.0.1:3737/mcp\n".to_string(), + stderr: String::new(), + }) + }) + .expect_err("conflicting project config should fail"); + + assert!( + error + .to_string() + .contains("conflicting `memory-bank` MCP server outside user scope") + ); + } + + #[test] + fn ensure_claude_user_mcp_accepts_verification_after_add_failure() { + let responses = RefCell::new(VecDeque::from([ + CommandOutcome { + program: "claude".to_string(), + args: vec!["mcp".to_string(), "get".to_string(), "memory-bank".to_string()], + exit_code: Some(1), + success: false, + stdout: String::new(), + stderr: "not found".to_string(), + }, + CommandOutcome { + program: "claude".to_string(), + args: vec![ + "mcp".to_string(), + "add".to_string(), + "--transport".to_string(), + "http".to_string(), + "--scope".to_string(), + "user".to_string(), + "memory-bank".to_string(), + "http://127.0.0.1:3737/mcp".to_string(), + ], + exit_code: Some(1), + success: false, + stdout: String::new(), + stderr: "transient add failure".to_string(), + }, + CommandOutcome { + program: "claude".to_string(), + args: vec!["mcp".to_string(), "get".to_string(), "memory-bank".to_string()], + exit_code: Some(0), + success: true, + stdout: "memory-bank:\n Scope: User config\n Type: http\n URL: http://127.0.0.1:3737/mcp\n".to_string(), + stderr: String::new(), + }, + ])); + + ensure_claude_user_mcp_with_runner("http://127.0.0.1:3737", |_| { + responses + .borrow_mut() + .pop_front() + .ok_or_else(|| AppError::Message("missing scripted response".to_string())) + }) + .expect("verify should recover after add failure"); + } + + #[test] + fn real_claude_configuration_round_trip() { + let Some(env) = RealBinaryTestEnv::for_binary("claude") else { + return; + }; + env.seed_agent_artifacts().expect("agent artifacts"); + + configure_claude_with_options(&env.paths, "http://127.0.0.1:3737", &env.command_options()) + .expect("configure claude"); + + let outcome = env + .run_cli("claude", &["mcp", "get", "memory-bank"]) + .expect("claude mcp get"); + + assert!(outcome.success); + let output = outcome.combined_output(); + assert!(output.contains("Scope: User config")); + assert!(output.contains("Type: http")); + assert!(output.contains("http://127.0.0.1:3737/mcp")); + + let settings = + fs::read_to_string(env.paths.home_dir.join(".claude/settings.json")).expect("settings"); + assert!(settings.contains("UserPromptSubmit")); + assert!(settings.contains("PostToolUse")); + assert!(settings.contains("memory-bank-hook")); + } + + #[test] + fn real_gemini_configuration_round_trip() { + let Some(env) = RealBinaryTestEnv::for_binary("gemini") else { + return; + }; + env.seed_agent_artifacts().expect("agent artifacts"); + env.seed_gemini_home().expect("gemini home"); + + configure_gemini(&env.paths, "http://127.0.0.1:3737").expect("configure gemini"); + + let outcome = env + .run_cli("gemini", &["mcp", "list"]) + .expect("gemini mcp list"); + + assert!(outcome.success); + let output = outcome.combined_output(); + assert!(output.contains("memory-bank")); + assert!(output.contains("http://127.0.0.1:3737/mcp")); + } + + #[test] + fn real_opencode_configuration_round_trip() { + let Some(env) = RealBinaryTestEnv::for_binary("opencode") else { + return; + }; + env.seed_agent_artifacts().expect("agent artifacts"); + + configure_opencode(&env.paths, "http://127.0.0.1:3737").expect("configure opencode"); + + let outcome = env + .run_cli("opencode", &["mcp", "list"]) + .expect("opencode mcp list"); + + assert!(outcome.success); + let output = outcome.combined_output(); + assert!(output.contains("memory-bank")); + assert!(output.contains("http://127.0.0.1:3737/mcp")); + } + + #[test] + fn real_openclaw_configuration_round_trip() { + let Some(env) = RealBinaryTestEnv::for_binary("openclaw") else { + return; + }; + env.seed_agent_artifacts().expect("agent artifacts"); + + configure_openclaw(&env.paths, "http://127.0.0.1:3737").expect("configure openclaw"); + + let outcome = env + .run_cli("openclaw", &["config", "validate", "--json"]) + .expect("openclaw validate"); + + assert!(outcome.success); + let output = outcome.combined_output(); + assert!(output.contains(r#""valid":true"#) || output.contains(r#""valid": true"#)); + + let settings = + fs::read_to_string(env.paths.home_dir.join(".openclaw/openclaw.json")).expect("config"); + assert!(settings.contains("memory-bank-mcp-proxy")); + assert!(settings.contains("memory-bank-hook")); + assert!(settings.contains("http://127.0.0.1:3737")); + } +} diff --git a/memory-bank-cli/src/agents/claude.rs b/memory-bank-cli/src/agents/claude.rs new file mode 100644 index 0000000..b6d8d3e --- /dev/null +++ b/memory-bank-cli/src/agents/claude.rs @@ -0,0 +1,166 @@ +use crate::AppError; +use crate::command_utils::{CommandOutcome, run_command_capture}; +#[cfg(test)] +use crate::command_utils::{CommandRunOptions, run_command_capture_with_options}; +use crate::constants::HOOK_BINARY_NAME; +use crate::json_config::{ + ensure_object, load_json_config, object_mut, write_json_config_with_backups, +}; +use memory_bank_app::AppPaths; +use serde_json::{Value, json}; + +use super::shared::{build_hook_command, ensure_child_array, ensure_child_object}; + +pub(super) fn configure(paths: &AppPaths, server_url: &str) -> Result<(), AppError> { + ensure_user_mcp(server_url)?; + + let settings_path = paths.home_dir.join(".claude/settings.json"); + let mut root = load_json_config(&settings_path)?; + let events = ["UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"]; + for event in events { + let command = build_hook_command( + &paths.binary_path(HOOK_BINARY_NAME), + "claude-code", + event, + server_url, + ); + upsert_hook(&mut root, event, &command)?; + } + write_json_config_with_backups(paths, &settings_path, &root) +} + +#[cfg(test)] +pub(super) fn configure_with_options( + paths: &AppPaths, + server_url: &str, + options: &CommandRunOptions, +) -> Result<(), AppError> { + ensure_user_mcp_with_runner(server_url, |args| { + run_command_capture_with_options("claude", args, options) + })?; + + let settings_path = paths.home_dir.join(".claude/settings.json"); + let mut root = load_json_config(&settings_path)?; + let events = ["UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"]; + for event in events { + let command = build_hook_command( + &paths.binary_path(HOOK_BINARY_NAME), + "claude-code", + event, + server_url, + ); + upsert_hook(&mut root, event, &command)?; + } + write_json_config_with_backups(paths, &settings_path, &root) +} + +pub(super) fn ensure_user_mcp(server_url: &str) -> Result<(), AppError> { + ensure_user_mcp_with_runner(server_url, |args| run_command_capture("claude", args)) +} + +pub(super) fn ensure_user_mcp_with_runner(server_url: &str, mut run: F) -> Result<(), AppError> +where + F: FnMut(&[&str]) -> Result, +{ + let desired_url = format!("{server_url}/mcp"); + let current = run(&["mcp", "get", "memory-bank"])?; + + if mcp_matches(¤t, &desired_url) { + return Ok(()); + } + + if current.success { + if mcp_scope(¤t).as_deref() == Some("user") { + let removal = run(&["mcp", "remove", "memory-bank", "-s", "user"])?; + if !removal.success { + return Err(removal.into_error()); + } + } else { + return Err(AppError::Message( + "Claude Code already has a conflicting `memory-bank` MCP server outside user scope; remove or rename that entry before rerunning setup".to_string(), + )); + } + } + + let addition = run(&[ + "mcp", + "add", + "--transport", + "http", + "--scope", + "user", + "memory-bank", + &desired_url, + ])?; + + if !addition.success { + let verify = run(&["mcp", "get", "memory-bank"])?; + if mcp_matches(&verify, &desired_url) { + return Ok(()); + } + return Err(addition.into_error()); + } + + let verify = run(&["mcp", "get", "memory-bank"])?; + if mcp_matches(&verify, &desired_url) { + Ok(()) + } else { + Err(AppError::Message(format!( + "Claude Code did not report the expected user-scoped HTTP MCP config for memory-bank after setup. Expected URL: {desired_url}" + ))) + } +} + +pub(super) fn mcp_matches(outcome: &CommandOutcome, desired_url: &str) -> bool { + outcome.success + && mcp_scope(outcome).as_deref() == Some("user") + && outcome.combined_output().contains("Type: http") + && outcome.combined_output().contains(desired_url) +} + +pub(super) fn mcp_scope(outcome: &CommandOutcome) -> Option { + for line in outcome.combined_output().lines() { + let trimmed = line.trim(); + if let Some(scope) = trimmed.strip_prefix("Scope:") { + let scope = scope.trim().to_ascii_lowercase(); + if scope.starts_with("user") { + return Some("user".to_string()); + } + if scope.starts_with("project") || scope.starts_with("local") { + return Some("project".to_string()); + } + return Some(scope); + } + } + None +} + +pub(super) fn upsert_hook(root: &mut Value, event: &str, command: &str) -> Result<(), AppError> { + ensure_object(root); + let root_map = object_mut(root)?; + let hooks_map = ensure_child_object(root_map, "hooks")?; + let groups_array = ensure_child_array(hooks_map, event)?; + let marker = format!("--agent claude-code --event {event}"); + let desired = json!({ + "type": "command", + "command": command, + }); + if let Some(existing) = groups_array.iter_mut().find_map(|group| { + group + .get_mut("hooks") + .and_then(Value::as_array_mut) + .and_then(|hooks| { + hooks.iter_mut().find(|hook| { + hook.get("command") + .and_then(Value::as_str) + .map(|value| value.contains(&marker)) + .unwrap_or(false) + }) + }) + }) { + *existing = desired; + } else { + groups_array.push(json!({ "hooks": [desired] })); + } + Ok(()) +} diff --git a/memory-bank-cli/src/agents/gemini.rs b/memory-bank-cli/src/agents/gemini.rs new file mode 100644 index 0000000..c123e4d --- /dev/null +++ b/memory-bank-cli/src/agents/gemini.rs @@ -0,0 +1,89 @@ +use crate::AppError; +use crate::constants::HOOK_BINARY_NAME; +use crate::json_config::{ + ensure_object, load_json_config, object_mut, write_json_config_with_backups, +}; +use memory_bank_app::AppPaths; +use serde_json::{Value, json}; + +use super::shared::{build_hook_command, ensure_child_array, ensure_child_object}; + +pub(super) fn configure(paths: &AppPaths, server_url: &str) -> Result<(), AppError> { + let settings_path = paths.home_dir.join(".gemini/settings.json"); + let mut root = load_json_config(&settings_path)?; + ensure_object(&mut root); + ensure_child_object(object_mut(&mut root)?, "mcpServers")?.insert( + "memory-bank".to_string(), + json!({ "httpUrl": format!("{server_url}/mcp") }), + ); + + let hook_events = [ + ("BeforeAgent", "*"), + ("BeforeTool", ".*"), + ("AfterTool", ".*"), + ("AfterAgent", "*"), + ]; + for (event, matcher) in hook_events { + let command = build_hook_command( + &paths.binary_path(HOOK_BINARY_NAME), + "gemini-cli", + event, + server_url, + ); + upsert_hook(&mut root, event, matcher, &command)?; + } + write_json_config_with_backups(paths, &settings_path, &root) +} + +pub(super) fn upsert_hook( + root: &mut Value, + event: &str, + matcher: &str, + command: &str, +) -> Result<(), AppError> { + ensure_object(root); + let root_map = object_mut(root)?; + let hooks_map = ensure_child_object(root_map, "hooks")?; + let groups_array = ensure_child_array(hooks_map, event)?; + let desired_hook = json!({ + "name": "memory-bank", + "type": "command", + "command": command, + }); + if let Some(existing_group) = groups_array.iter_mut().find(|group| { + group + .get("hooks") + .and_then(Value::as_array) + .map(|hooks| { + hooks.iter().any(|hook| { + hook.get("name") + .and_then(Value::as_str) + .map(|value| value == "memory-bank") + .unwrap_or(false) + }) + }) + .unwrap_or(false) + }) { + existing_group["matcher"] = Value::String(matcher.to_string()); + existing_group["sequential"] = Value::Bool(true); + if let Some(hooks) = existing_group + .get_mut("hooks") + .and_then(Value::as_array_mut) + && let Some(existing_hook) = hooks.iter_mut().find(|hook| { + hook.get("name") + .and_then(Value::as_str) + .map(|value| value == "memory-bank") + .unwrap_or(false) + }) + { + *existing_hook = desired_hook; + } + } else { + groups_array.push(json!({ + "matcher": matcher, + "sequential": true, + "hooks": [desired_hook], + })); + } + Ok(()) +} diff --git a/memory-bank-cli/src/agents/openclaw.rs b/memory-bank-cli/src/agents/openclaw.rs new file mode 100644 index 0000000..1781bda --- /dev/null +++ b/memory-bank-cli/src/agents/openclaw.rs @@ -0,0 +1,72 @@ +use crate::AppError; +use crate::constants::{HOOK_BINARY_NAME, MCP_PROXY_BINARY_NAME}; +use crate::json_config::{ + array_mut, ensure_object, load_json_config, object_mut, write_json_config_with_backups, +}; +use memory_bank_app::AppPaths; +use serde_json::{Map, Value, json}; + +use super::shared::ensure_child_object; + +pub(super) fn configure(paths: &AppPaths, server_url: &str) -> Result<(), AppError> { + let extension_path = paths.integrations_dir.join("openclaw/memory-bank"); + let settings_path = paths.home_dir.join(".openclaw/openclaw.json"); + let mut root = load_json_config(&settings_path)?; + ensure_object(&mut root); + let root_map = object_mut(&mut root)?; + let mcp = ensure_child_object(root_map, "mcp")?; + ensure_child_object(mcp, "servers")?.insert( + "memory-bank".to_string(), + json!({ + "command": paths.binary_path(MCP_PROXY_BINARY_NAME), + "args": ["--server-url", server_url] + }), + ); + + let plugins = ensure_child_object(root_map, "plugins")?; + upsert_plugin_load_path( + ensure_child_object(plugins, "load")?, + extension_path.to_string_lossy().as_ref(), + )?; + ensure_child_object(plugins, "entries")?.insert( + "memory-bank".to_string(), + json!({ + "enabled": true, + "config": { + "hookBinary": paths.binary_path(HOOK_BINARY_NAME), + "serverUrl": server_url + } + }), + ); + ensure_child_object(plugins, "slots")? + .insert("memory".to_string(), Value::String("none".to_string())); + + write_json_config_with_backups(paths, &settings_path, &root) +} + +pub(super) fn upsert_plugin_load_path( + load_map: &mut Map, + desired_path: &str, +) -> Result<(), AppError> { + let desired = desired_path.to_string(); + let paths_value = load_map + .entry("paths".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + let paths_array = array_mut(paths_value)?; + + paths_array.retain(|value| { + let Some(path) = value.as_str() else { + return true; + }; + !(path.ends_with("/memory-bank") && path != desired) + }); + + if !paths_array + .iter() + .any(|value| value.as_str() == Some(desired.as_str())) + { + paths_array.push(Value::String(desired)); + } + + Ok(()) +} diff --git a/memory-bank-cli/src/agents/opencode.rs b/memory-bank-cli/src/agents/opencode.rs new file mode 100644 index 0000000..fc937c2 --- /dev/null +++ b/memory-bank-cli/src/agents/opencode.rs @@ -0,0 +1,32 @@ +use crate::AppError; +use crate::assets::copy_if_needed; +use crate::json_config::{ + ensure_object, load_json_config, object_mut, write_json_config_with_backups, +}; +use memory_bank_app::AppPaths; +use serde_json::json; + +use super::shared::ensure_child_object; + +pub(super) fn configure(paths: &AppPaths, server_url: &str) -> Result<(), AppError> { + let plugin_target = paths + .home_dir + .join(".config/opencode/plugins/memory-bank.js"); + copy_if_needed( + &paths.integrations_dir.join("opencode/memory-bank.js"), + &plugin_target, + )?; + + let settings_path = paths.home_dir.join(".config/opencode/opencode.json"); + let mut root = load_json_config(&settings_path)?; + ensure_object(&mut root); + ensure_child_object(object_mut(&mut root)?, "mcp")?.insert( + "memory-bank".to_string(), + json!({ + "type": "remote", + "url": format!("{server_url}/mcp"), + "enabled": true + }), + ); + write_json_config_with_backups(paths, &settings_path, &root) +} diff --git a/memory-bank-cli/src/agents/shared.rs b/memory-bank-cli/src/agents/shared.rs new file mode 100644 index 0000000..f6942be --- /dev/null +++ b/memory-bank-cli/src/agents/shared.rs @@ -0,0 +1,48 @@ +use crate::AppError; +use crate::command_utils::shell_escape; +use serde_json::{Map, Value}; + +pub(super) fn build_hook_command( + hook_binary: &std::path::Path, + agent: &str, + event: &str, + server_url: &str, +) -> String { + format!( + "{} --agent {} --event {} --server-url {}", + shell_escape(hook_binary.to_string_lossy().as_ref()), + shell_escape(agent), + shell_escape(event), + shell_escape(server_url) + ) +} + +pub(super) fn ensure_child_object<'a>( + parent: &'a mut Map, + key: &str, +) -> Result<&'a mut Map, AppError> { + let child = parent + .entry(key.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if !child.is_object() { + *child = Value::Object(Map::new()); + } + child + .as_object_mut() + .ok_or_else(|| AppError::Message("expected JSON object".to_string())) +} + +pub(super) fn ensure_child_array<'a>( + parent: &'a mut Map, + key: &str, +) -> Result<&'a mut Vec, AppError> { + let child = parent + .entry(key.to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + if !child.is_array() { + *child = Value::Array(Vec::new()); + } + child + .as_array_mut() + .ok_or_else(|| AppError::Message("expected JSON array".to_string())) +} diff --git a/memory-bank-cli/src/assets.rs b/memory-bank-cli/src/assets.rs new file mode 100644 index 0000000..ad2f5b7 --- /dev/null +++ b/memory-bank-cli/src/assets.rs @@ -0,0 +1,718 @@ +use crate::AppError; +use crate::constants::{ + EMBEDDED_MODEL_CATALOG, EMBEDDED_OPENCLAW_INDEX, EMBEDDED_OPENCLAW_MANIFEST, + EMBEDDED_OPENCLAW_PACKAGE, EMBEDDED_OPENCODE_PLUGIN, HOOK_BINARY_NAME, MB_BINARY_NAME, + MCP_PROXY_BINARY_NAME, SERVER_BINARY_NAME, +}; +use memory_bank_app::AppPaths; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Component; +use std::path::{Path, PathBuf}; + +const MANAGED_LAUNCHER_MARKER: &str = "# Memory Bank managed launcher"; +const MANAGED_LAUNCHER_EXEC_LINE: &str = r#"exec "$HOME/.memory_bank/bin/mb" "$@""#; +const MANAGED_ENV_MARKER: &str = "# Memory Bank managed environment"; +const RC_BLOCK_START: &str = "# >>> Memory Bank >>>"; +const RC_BLOCK_END: &str = "# <<< Memory Bank <<<"; +const RC_SOURCE_LINE: &str = + r#"[ -f "$HOME/.memory_bank/env.sh" ] && . "$HOME/.memory_bank/env.sh""#; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ExposureMode { + Direct, + Launcher, + ShellInitFallback, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExposureOutcome { + pub(crate) mode: ExposureMode, + pub(crate) bare_command_works_now: bool, + pub(crate) command_prefix: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ExposureCheck { + Active(ExposureOutcome), + Missing, + Collision(PathBuf), +} + +pub(crate) fn materialize_install_artifacts(paths: &AppPaths) -> Result<(), AppError> { + paths.ensure_base_dirs()?; + let current_exe = std::env::current_exe()?; + copy_if_needed(¤t_exe, &paths.binary_path(MB_BINARY_NAME))?; + + let executable_dir = current_exe.parent().ok_or_else(|| { + AppError::Message("failed to resolve current executable directory".to_string()) + })?; + for binary in [SERVER_BINARY_NAME, HOOK_BINARY_NAME, MCP_PROXY_BINARY_NAME] { + let source = executable_dir.join(binary); + let target = paths.binary_path(binary); + if source.exists() { + copy_if_needed(&source, &target)?; + } else if !target.exists() { + return Err(AppError::MissingBinary(binary.to_string())); + } + } + + install_assets(paths)?; + Ok(()) +} + +pub(crate) fn materialize_and_expose_cli(paths: &AppPaths) -> Result { + materialize_install_artifacts(paths)?; + ensure_cli_exposure(paths) +} + +pub(crate) fn ensure_cli_exposure(paths: &AppPaths) -> Result { + let path_entries = current_path_entries(); + let shell = current_shell(); + ensure_cli_exposure_with_context(paths, &path_entries, &shell) +} + +pub(crate) fn inspect_cli_exposure(paths: &AppPaths) -> Result { + let path_entries = current_path_entries(); + let shell = current_shell(); + inspect_cli_exposure_with_context(paths, &path_entries, &shell) +} + +pub(crate) fn find_on_path(binary: &str) -> Option { + which::which(binary).ok() +} + +pub(crate) fn find_repo_root() -> Option { + let mut candidates = Vec::new(); + if let Ok(current_dir) = std::env::current_dir() { + candidates.push(current_dir); + } + + let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .map(Path::to_path_buf); + if let Some(root) = workspace_root { + candidates.push(root); + } + + for candidate in candidates { + let mut current = Some(candidate.as_path()); + while let Some(path) = current { + if path.join(".opencode/plugins/memory-bank.js").exists() + && path.join(".openclaw/extensions/memory-bank").exists() + { + return Some(path.to_path_buf()); + } + current = path.parent(); + } + } + + None +} + +pub(crate) fn assets_are_installed(paths: &AppPaths) -> bool { + paths + .integrations_dir + .join("opencode") + .join("memory-bank.js") + .is_file() + && paths + .integrations_dir + .join("openclaw") + .join("memory-bank") + .join("index.js") + .is_file() + && paths + .integrations_dir + .join("openclaw") + .join("memory-bank") + .join("openclaw.plugin.json") + .is_file() + && paths + .integrations_dir + .join("openclaw") + .join("memory-bank") + .join("package.json") + .is_file() + && paths.model_catalog_file.is_file() +} + +pub(crate) fn install_embedded_assets(paths: &AppPaths) -> Result<(), AppError> { + write_asset_file( + &paths + .integrations_dir + .join("opencode") + .join("memory-bank.js"), + EMBEDDED_OPENCODE_PLUGIN, + )?; + + let openclaw_target = paths.integrations_dir.join("openclaw").join("memory-bank"); + write_asset_file(&openclaw_target.join("index.js"), EMBEDDED_OPENCLAW_INDEX)?; + write_asset_file( + &openclaw_target.join("openclaw.plugin.json"), + EMBEDDED_OPENCLAW_MANIFEST, + )?; + write_asset_file( + &openclaw_target.join("package.json"), + EMBEDDED_OPENCLAW_PACKAGE, + )?; + write_asset_file(&paths.model_catalog_file, EMBEDDED_MODEL_CATALOG)?; + Ok(()) +} + +fn install_assets(paths: &AppPaths) -> Result<(), AppError> { + if assets_are_installed(paths) { + return Ok(()); + } + + if let Some(repo_root) = find_repo_root() + && install_repo_assets(paths, &repo_root).is_ok() + { + return Ok(()); + } + + install_embedded_assets(paths) +} + +fn install_repo_assets(paths: &AppPaths, repo_root: &Path) -> Result<(), AppError> { + let opencode_target = paths + .integrations_dir + .join("opencode") + .join("memory-bank.js"); + let openclaw_target = paths.integrations_dir.join("openclaw").join("memory-bank"); + let model_catalog_target = &paths.model_catalog_file; + let opencode_source = repo_root.join(".opencode/plugins/memory-bank.js"); + let openclaw_source = repo_root.join(".openclaw/extensions/memory-bank"); + let model_catalog_source = repo_root.join("config/setup-model-catalog.json"); + + if !opencode_source.exists() || !openclaw_source.exists() || !model_catalog_source.exists() { + return Err(AppError::Message( + "repo asset sources for installation are missing".to_string(), + )); + } + + copy_if_needed(&opencode_source, &opencode_target)?; + copy_dir_recursive(&openclaw_source, &openclaw_target)?; + copy_if_needed(&model_catalog_source, model_catalog_target)?; + Ok(()) +} + +pub(crate) fn copy_if_needed(source: &Path, target: &Path) -> Result<(), AppError> { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + + if source == target { + return Ok(()); + } + + fs::copy(source, target)?; + let permissions = source.metadata()?.permissions(); + fs::set_permissions(target, permissions)?; + Ok(()) +} + +fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), AppError> { + fs::create_dir_all(target)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + if source_path.is_dir() { + copy_dir_recursive(&source_path, &target_path)?; + } else { + copy_if_needed(&source_path, &target_path)?; + } + } + Ok(()) +} + +fn write_asset_file(path: &Path, contents: &str) -> Result<(), AppError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + fs::write(path, contents)?; + Ok(()) +} + +fn ensure_cli_exposure_with_context( + paths: &AppPaths, + path_entries: &[PathBuf], + shell: &str, +) -> Result { + let resolved_mb = find_on_entries(path_entries, MB_BINARY_NAME); + let real_binary = paths.binary_path(MB_BINARY_NAME); + + if let Some(path) = resolved_mb { + if same_path(&path, &real_binary) { + return Ok(direct_exposure(paths)); + } + if is_managed_launcher(&path, paths) { + return Ok(launcher_exposure(paths)); + } + return Err(cli_collision_error(&path)); + } + + if is_path_entry_present(path_entries, &paths.bin_dir) { + return Ok(direct_exposure(paths)); + } + + if let Some(launcher_dir) = first_launcher_dir(paths, path_entries) { + install_managed_launcher(&launcher_dir.join(MB_BINARY_NAME), paths)?; + return Ok(launcher_exposure(paths)); + } + + install_shell_init_fallback(paths, shell)?; + Ok(shell_init_exposure(paths)) +} + +fn inspect_cli_exposure_with_context( + paths: &AppPaths, + path_entries: &[PathBuf], + shell: &str, +) -> Result { + let resolved_mb = find_on_entries(path_entries, MB_BINARY_NAME); + let real_binary = paths.binary_path(MB_BINARY_NAME); + + if let Some(path) = resolved_mb { + if same_path(&path, &real_binary) { + return Ok(ExposureCheck::Active(direct_exposure(paths))); + } + if is_managed_launcher(&path, paths) { + return Ok(ExposureCheck::Active(launcher_exposure(paths))); + } + return Ok(ExposureCheck::Collision(path)); + } + + if is_path_entry_present(path_entries, &paths.bin_dir) && is_executable_file(&real_binary) { + return Ok(ExposureCheck::Active(direct_exposure(paths))); + } + + if shell_init_fallback_is_managed(paths, shell)? { + return Ok(ExposureCheck::Active(shell_init_exposure(paths))); + } + + Ok(ExposureCheck::Missing) +} + +fn direct_exposure(_paths: &AppPaths) -> ExposureOutcome { + ExposureOutcome { + mode: ExposureMode::Direct, + bare_command_works_now: true, + command_prefix: MB_BINARY_NAME.to_string(), + } +} + +fn launcher_exposure(_paths: &AppPaths) -> ExposureOutcome { + ExposureOutcome { + mode: ExposureMode::Launcher, + bare_command_works_now: true, + command_prefix: MB_BINARY_NAME.to_string(), + } +} + +fn shell_init_exposure(paths: &AppPaths) -> ExposureOutcome { + ExposureOutcome { + mode: ExposureMode::ShellInitFallback, + bare_command_works_now: false, + command_prefix: paths.binary_path(MB_BINARY_NAME).display().to_string(), + } +} + +fn cli_collision_error(path: &Path) -> AppError { + AppError::Message(format!( + "cannot expose `mb` because another executable already exists on PATH at {}", + path.display() + )) +} + +fn current_path_entries() -> Vec { + std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect()) + .unwrap_or_default() +} + +fn current_shell() -> String { + std::env::var("SHELL").unwrap_or_default() +} + +fn find_on_entries(entries: &[PathBuf], binary: &str) -> Option { + for entry in entries { + let candidate = entry.join(binary); + if is_executable_file(&candidate) { + return Some(candidate); + } + } + None +} + +fn first_launcher_dir(paths: &AppPaths, path_entries: &[PathBuf]) -> Option { + let local_bin = paths.home_dir.join(".local/bin"); + let home_bin = paths.home_dir.join("bin"); + + path_entries.iter().find_map(|entry| { + if same_path(entry, &local_bin) || same_path(entry, &home_bin) { + Some(entry.clone()) + } else { + None + } + }) +} + +fn install_managed_launcher(path: &Path, paths: &AppPaths) -> Result<(), AppError> { + if path.exists() && !is_managed_launcher(path, paths) { + return Err(cli_collision_error(path)); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, render_managed_launcher())?; + set_mode(path, 0o755)?; + Ok(()) +} + +fn render_managed_launcher() -> String { + format!("#!/usr/bin/env sh\n{MANAGED_LAUNCHER_MARKER}\n{MANAGED_LAUNCHER_EXEC_LINE}\n") +} + +fn is_managed_launcher(path: &Path, _paths: &AppPaths) -> bool { + let Ok(contents) = fs::read_to_string(path) else { + return false; + }; + contents.contains(MANAGED_LAUNCHER_MARKER) + && contents.contains(MANAGED_LAUNCHER_EXEC_LINE) + && path.file_name().and_then(|name| name.to_str()) == Some(MB_BINARY_NAME) +} + +fn install_shell_init_fallback(paths: &AppPaths, shell: &str) -> Result<(), AppError> { + let env_file = shell_env_file(paths); + if let Some(parent) = env_file.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&env_file, render_managed_env_file())?; + + for target in shell_init_targets_for_shell(paths, shell) { + ensure_source_block(&target)?; + } + Ok(()) +} + +fn shell_init_fallback_is_managed(paths: &AppPaths, shell: &str) -> Result { + let env_file = shell_env_file(paths); + if !matches!(fs::read_to_string(&env_file), Ok(contents) if contents == render_managed_env_file()) + { + return Ok(false); + } + + for target in shell_init_targets_for_shell(paths, shell) { + if !has_source_block(&target)? { + return Ok(false); + } + } + Ok(true) +} + +fn shell_env_file(paths: &AppPaths) -> PathBuf { + paths.root.join("env.sh") +} + +fn render_managed_env_file() -> String { + format!( + "{MANAGED_ENV_MARKER}\ncase \":$PATH:\" in\n *\":$HOME/.memory_bank/bin:\"*) ;;\n *) export PATH=\"$HOME/.memory_bank/bin:$PATH\" ;;\nesac\n" + ) +} + +fn shell_init_targets_for_shell(paths: &AppPaths, shell: &str) -> Vec { + if shell.ends_with("zsh") { + vec![ + paths.home_dir.join(".zprofile"), + paths.home_dir.join(".zshrc"), + ] + } else if shell.ends_with("bash") { + vec![bash_login_file(paths), paths.home_dir.join(".bashrc")] + } else { + vec![paths.home_dir.join(".profile")] + } +} + +fn bash_login_file(paths: &AppPaths) -> PathBuf { + for candidate in [ + paths.home_dir.join(".bash_profile"), + paths.home_dir.join(".bash_login"), + paths.home_dir.join(".profile"), + ] { + if candidate.exists() { + return candidate; + } + } + paths.home_dir.join(".bash_profile") +} + +fn ensure_source_block(path: &Path) -> Result<(), AppError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let block = render_source_block(); + let existing = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(AppError::Io(error)), + }; + if existing.contains(&block) { + return Ok(()); + } + + let updated = replace_or_append_block(&existing, &block); + fs::write(path, updated)?; + Ok(()) +} + +fn has_source_block(path: &Path) -> Result { + Ok(fs::read_to_string(path) + .map(|contents| contents.contains(&render_source_block())) + .unwrap_or(false)) +} + +fn render_source_block() -> String { + format!("{RC_BLOCK_START}\n{RC_SOURCE_LINE}\n{RC_BLOCK_END}\n") +} + +fn replace_or_append_block(existing: &str, block: &str) -> String { + if let Some(start) = existing.find(RC_BLOCK_START) + && let Some(end_offset) = existing[start..].find(RC_BLOCK_END) + { + let end = start + end_offset + RC_BLOCK_END.len(); + let mut updated = String::new(); + updated.push_str(&existing[..start]); + if !updated.ends_with('\n') && !updated.is_empty() { + updated.push('\n'); + } + updated.push_str(block); + let suffix = &existing[end..]; + if !suffix.is_empty() && !suffix.starts_with('\n') { + updated.push('\n'); + } + updated.push_str(suffix.trim_start_matches('\n')); + if !updated.ends_with('\n') { + updated.push('\n'); + } + return updated; + } + + let mut updated = existing.to_string(); + if !updated.ends_with('\n') && !updated.is_empty() { + updated.push('\n'); + } + updated.push_str(block); + updated +} + +fn is_path_entry_present(entries: &[PathBuf], target: &Path) -> bool { + entries.iter().any(|entry| same_path(entry, target)) +} + +fn same_path(left: &Path, right: &Path) -> bool { + normalize_components(left).eq(normalize_components(right)) +} + +fn normalize_components(path: &Path) -> impl Iterator> { + path.components() + .filter(|component| *component != Component::CurDir) +} + +fn set_mode(path: &Path, mode: u32) -> Result<(), AppError> { + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(mode); + fs::set_permissions(path, permissions)?; + Ok(()) +} + +fn is_executable_file(path: &Path) -> bool { + path.is_file() + && path + .metadata() + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn ensure_cli_exposure_uses_direct_mode_when_app_bin_is_on_path() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + paths.ensure_base_dirs().expect("base dirs"); + fs::write(paths.binary_path(MB_BINARY_NAME), "#!/bin/sh\n").expect("write mb"); + set_mode(&paths.binary_path(MB_BINARY_NAME), 0o755).expect("chmod"); + + let outcome = ensure_cli_exposure_with_context( + &paths, + std::slice::from_ref(&paths.bin_dir), + "/bin/zsh", + ) + .expect("direct exposure"); + + assert_eq!(outcome.mode, ExposureMode::Direct); + assert!(outcome.bare_command_works_now); + assert_eq!(outcome.command_prefix, "mb"); + } + + #[test] + fn ensure_cli_exposure_creates_managed_launcher_in_first_path_match() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + + let launcher_dir = paths.home_dir.join(".local/bin"); + let other_dir = paths.home_dir.join("bin"); + let outcome = ensure_cli_exposure_with_context( + &paths, + &[launcher_dir.clone(), other_dir], + "/bin/bash", + ) + .expect("launcher exposure"); + + assert_eq!(outcome.mode, ExposureMode::Launcher); + assert!(outcome.bare_command_works_now); + assert_eq!( + fs::read_to_string(launcher_dir.join(MB_BINARY_NAME)).expect("launcher"), + render_managed_launcher() + ); + } + + #[test] + fn ensure_cli_exposure_rejects_unrelated_mb_collision() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let collision_dir = paths.home_dir.join(".local/bin"); + fs::create_dir_all(&collision_dir).expect("collision dir"); + let collision_path = collision_dir.join(MB_BINARY_NAME); + fs::write(&collision_path, "#!/bin/sh\nexit 0\n").expect("write collision"); + set_mode(&collision_path, 0o755).expect("chmod"); + + let error = ensure_cli_exposure_with_context(&paths, &[collision_dir], "/bin/bash") + .expect_err("collision"); + + assert!( + error + .to_string() + .contains("another executable already exists on PATH") + ); + } + + #[test] + fn ensure_cli_exposure_falls_back_to_managed_shell_init() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + + let outcome = + ensure_cli_exposure_with_context(&paths, &[PathBuf::from("/usr/bin")], "/bin/zsh") + .expect("shell init fallback"); + + assert_eq!(outcome.mode, ExposureMode::ShellInitFallback); + assert!(!outcome.bare_command_works_now); + assert_eq!( + outcome.command_prefix, + paths.binary_path(MB_BINARY_NAME).display().to_string() + ); + assert_eq!( + fs::read_to_string(shell_env_file(&paths)).expect("env file"), + render_managed_env_file() + ); + assert!(has_source_block(&paths.home_dir.join(".zprofile")).expect("zprofile")); + assert!(has_source_block(&paths.home_dir.join(".zshrc")).expect("zshrc")); + } + + #[test] + fn shell_init_fallback_is_idempotent() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + + install_shell_init_fallback(&paths, "/bin/bash").expect("first fallback"); + install_shell_init_fallback(&paths, "/bin/bash").expect("second fallback"); + + let bashrc = fs::read_to_string(paths.home_dir.join(".bashrc")).expect("bashrc"); + let bash_profile = + fs::read_to_string(paths.home_dir.join(".bash_profile")).expect("bash profile"); + + assert_eq!(bashrc.matches(RC_BLOCK_START).count(), 1); + assert_eq!(bash_profile.matches(RC_BLOCK_START).count(), 1); + } + + #[test] + fn ensure_source_block_errors_instead_of_clobbering_unreadable_paths() { + let temp = TempDir::new().expect("tempdir"); + let path = temp.path().join(".profile"); + fs::create_dir_all(&path).expect("directory placeholder"); + + let error = ensure_source_block(&path).expect_err("expected read failure"); + + assert!(matches!(error, AppError::Io(_))); + assert!(path.is_dir()); + } + + #[test] + fn inspect_cli_exposure_reports_shell_init_fallback_as_healthy() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + install_shell_init_fallback(&paths, "/bin/bash").expect("fallback"); + + let inspection = + inspect_cli_exposure_with_context(&paths, &[PathBuf::from("/usr/bin")], "/bin/bash") + .expect("inspection"); + + assert_eq!( + inspection, + ExposureCheck::Active(shell_init_exposure(&paths)) + ); + } + + #[test] + fn copy_if_needed_is_a_no_op_for_same_path() { + let temp = TempDir::new().expect("tempdir"); + let path = temp.path().join("file.txt"); + fs::write(&path, "hello").expect("write file"); + + copy_if_needed(&path, &path).expect("copy"); + + assert_eq!(fs::read_to_string(&path).expect("read file"), "hello"); + } + + #[test] + fn install_embedded_assets_writes_complete_bundle() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + + install_embedded_assets(&paths).expect("install embedded assets"); + + assert!(assets_are_installed(&paths)); + assert_eq!( + fs::read_to_string(paths.integrations_dir.join("opencode/memory-bank.js")) + .expect("opencode plugin"), + EMBEDDED_OPENCODE_PLUGIN + ); + assert_eq!( + fs::read_to_string(paths.model_catalog_file).expect("model catalog"), + EMBEDDED_MODEL_CATALOG + ); + } + + #[test] + fn assets_are_not_reported_installed_when_bundle_is_incomplete() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + fs::create_dir_all(paths.integrations_dir.join("opencode")).expect("opencode dir"); + fs::write( + paths.integrations_dir.join("opencode/memory-bank.js"), + "plugin", + ) + .expect("plugin"); + + assert!(!assets_are_installed(&paths)); + } +} diff --git a/memory-bank-cli/src/cli.rs b/memory-bank-cli/src/cli.rs index af639cc..ae541a1 100644 --- a/memory-bank-cli/src/cli.rs +++ b/memory-bank-cli/src/cli.rs @@ -1,7 +1,18 @@ +use crate::cli_help::{ + CONFIG_AFTER_HELP, CONFIG_LONG_ABOUT, CONFIG_SET_AFTER_HELP, DOCTOR_AFTER_HELP, + DOCTOR_LONG_ABOUT, LOGS_AFTER_HELP, LOGS_LONG_ABOUT, NAMESPACE_AFTER_HELP, + NAMESPACE_LONG_ABOUT, ROOT_AFTER_HELP, SERVICE_AFTER_HELP, SERVICE_LONG_ABOUT, + SETUP_AFTER_HELP, SETUP_LONG_ABOUT, STATUS_AFTER_HELP, STATUS_LONG_ABOUT, +}; use clap::{Parser, Subcommand}; #[derive(Debug, Parser)] -#[command(author, version, about = "Memory Bank control plane")] +#[command( + author, + version, + about = "Memory Bank control plane", + after_help = ROOT_AFTER_HELP +)] pub struct Cli { #[command(subcommand)] pub command: Command, @@ -9,24 +20,66 @@ pub struct Cli { #[derive(Debug, Subcommand)] pub enum Command { + #[command( + about = "Run guided setup for provider, service, and agent integrations", + long_about = SETUP_LONG_ABOUT, + after_help = SETUP_AFTER_HELP + )] Setup, + #[command( + about = "Check current Memory Bank health and configuration", + long_about = STATUS_LONG_ABOUT, + after_help = STATUS_AFTER_HELP + )] Status, + #[command( + about = "Diagnose common install and configuration problems", + long_about = DOCTOR_LONG_ABOUT, + after_help = DOCTOR_AFTER_HELP + )] Doctor { - #[arg(long)] + #[arg( + long, + help = "Attempt safe repairs such as exposing `mb`, installing the service, and starting it when configuration is valid" + )] fix: bool, }, + #[command( + about = "Read the managed service log", + long_about = LOGS_LONG_ABOUT, + after_help = LOGS_AFTER_HELP + )] Logs { - #[arg(short = 'f', long)] + #[arg( + short = 'f', + long, + help = "Keep streaming `~/.memory_bank/logs/server.log` as new lines arrive" + )] follow: bool, }, + #[command( + about = "Manage memory namespaces", + long_about = NAMESPACE_LONG_ABOUT, + after_help = NAMESPACE_AFTER_HELP + )] Namespace { #[command(subcommand)] command: NamespaceCommand, }, + #[command( + about = "Manage the user-scoped background service", + long_about = SERVICE_LONG_ABOUT, + after_help = SERVICE_AFTER_HELP + )] Service { #[command(subcommand)] command: ServiceCommand, }, + #[command( + about = "Inspect and edit saved configuration", + long_about = CONFIG_LONG_ABOUT, + after_help = CONFIG_AFTER_HELP + )] Config { #[command(subcommand)] command: ConfigCommand, @@ -40,33 +93,164 @@ pub enum Command { #[derive(Debug, Subcommand)] pub enum NamespaceCommand { + #[command(about = "List known namespaces and mark the active one")] List, - Create { name: String }, - Use { name: String }, + #[command( + about = "Create a namespace directory without switching to it", + long_about = "Create a namespace directory without switching to it.\n\nNamespace names are sanitized to letters, numbers, hyphens, and underscores." + )] + Create { + #[arg(help = "Namespace name to create. Invalid characters are replaced with underscores")] + name: String, + }, + #[command( + about = "Switch the active namespace", + long_about = "Switch the active namespace.\n\nIf the managed service is installed, `mb namespace use` restarts it when already running or starts it when stopped so the new namespace takes effect immediately." + )] + Use { + #[arg( + help = "Namespace name to activate. Invalid characters are replaced with underscores" + )] + name: String, + }, + #[command(about = "Print the active namespace")] Current, } #[derive(Debug, Subcommand)] pub enum ServiceCommand { + #[command( + about = "Install the managed service definition", + long_about = "Install the user-scoped Memory Bank service definition for the current platform.\n\nThis writes a launchd agent on macOS or a systemd --user unit on Linux." + )] Install, + #[command( + about = "Start the managed Memory Bank service", + long_about = "Start the managed Memory Bank service.\n\nIf the service definition is missing, `mb service start` installs it first." + )] Start, + #[command(about = "Stop the managed Memory Bank service")] Stop, + #[command(about = "Restart the managed Memory Bank service")] Restart, + #[command(about = "Show whether the managed service is installed and running")] Status, + #[command( + about = "Read the managed service log", + long_about = "Read the managed service log at `~/.memory_bank/logs/server.log`." + )] Logs { - #[arg(short = 'f', long)] + #[arg( + short = 'f', + long, + help = "Keep streaming `~/.memory_bank/logs/server.log` as new lines arrive" + )] follow: bool, }, } #[derive(Debug, Subcommand)] pub enum ConfigCommand { + #[command(about = "Print the saved settings file")] Show, - Get { key: String }, - Set { key: String, value: String }, + #[command( + about = "Read a single saved config value", + long_about = "Read a single saved config value from `~/.memory_bank/settings.toml`.\n\nRun `mb config --help` to see the supported key catalog." + )] + Get { + #[arg(help = "Config key to read. Run `mb config --help` to see supported keys")] + key: String, + }, + #[command( + about = "Update a single saved config value", + long_about = "Update a single saved config value in `~/.memory_bank/settings.toml`.", + after_help = CONFIG_SET_AFTER_HELP + )] + Set { + #[arg(help = "Config key to update. Run `mb config --help` to see supported keys")] + key: String, + #[arg( + help = "New value for the selected key. Use an empty string to clear optional string overrides" + )] + value: String, + #[arg( + short = 'y', + long, + help = "Skip confirmation prompts for changes that trigger follow-up work on the next service start" + )] + yes: bool, + }, } #[derive(Debug, Subcommand)] pub enum InternalCommand { RunServer, + BootstrapInstall, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_service_logs_follow_flag() { + let cli = Cli::try_parse_from(["mb", "service", "logs", "--follow"]).expect("parse cli"); + + assert!(matches!( + cli.command, + Command::Service { + command: ServiceCommand::Logs { follow: true } + } + )); + } + + #[test] + fn parses_config_set_command() { + let cli = Cli::try_parse_from(["mb", "config", "set", "service.port", "4545"]) + .expect("parse cli"); + + assert!(matches!( + cli.command, + Command::Config { + command: ConfigCommand::Set { key, value, yes: false } + } if key == "service.port" && value == "4545" + )); + } + + #[test] + fn parses_config_set_yes_flag() { + let cli = Cli::try_parse_from(["mb", "config", "set", "--yes", "service.port", "4545"]) + .expect("parse cli"); + + assert!(matches!( + cli.command, + Command::Config { + command: ConfigCommand::Set { key, value, yes: true } + } if key == "service.port" && value == "4545" + )); + } + + #[test] + fn parses_hidden_internal_command() { + let cli = Cli::try_parse_from(["mb", "internal", "run-server"]).expect("parse cli"); + + assert!(matches!( + cli.command, + Command::Internal { + command: InternalCommand::RunServer + } + )); + } + + #[test] + fn parses_hidden_bootstrap_install_command() { + let cli = Cli::try_parse_from(["mb", "internal", "bootstrap-install"]).expect("parse cli"); + + assert!(matches!( + cli.command, + Command::Internal { + command: InternalCommand::BootstrapInstall + } + )); + } } diff --git a/memory-bank-cli/src/cli_help.rs b/memory-bank-cli/src/cli_help.rs new file mode 100644 index 0000000..8376202 --- /dev/null +++ b/memory-bank-cli/src/cli_help.rs @@ -0,0 +1,189 @@ +pub(crate) const ROOT_AFTER_HELP: &str = "\ +Quick start: + mb setup Run guided setup for provider, service, and agent integrations + mb status Check health, namespace, provider, and integration status + mb doctor --fix Repair common install and configuration problems + +Common workflows: + mb namespace list See available memory namespaces + mb service logs -f Follow the managed service log + mb config show Review the saved settings file"; + +pub(crate) const SETUP_LONG_ABOUT: &str = "\ +Run guided first-run setup for Memory Bank. + +This interactive command helps you choose a namespace and provider, store any +required provider secret, configure the managed service, and wire supported +agents to Memory Bank. + +`mb setup` requires an interactive terminal."; + +pub(crate) const SETUP_AFTER_HELP: &str = "\ +Examples: + mb setup + +Next steps: + mb status Confirm the service is healthy + mb config show Review the saved configuration"; + +pub(crate) const STATUS_LONG_ABOUT: &str = "\ +Show the current Memory Bank status. + +This reports the active namespace, resolved port, managed service state, saved +provider and model selection, best-effort server health, and whether supported +agent integrations appear to be configured."; + +pub(crate) const STATUS_AFTER_HELP: &str = "\ +Examples: + mb status + +Related commands: + mb doctor Diagnose problems when status looks wrong + mb logs Read the managed service log"; + +pub(crate) const DOCTOR_LONG_ABOUT: &str = "\ +Diagnose common install and configuration problems. + +Doctor checks CLI exposure, managed service setup, health, and required +provider configuration. Use `--fix` to attempt safe repairs when possible."; + +pub(crate) const DOCTOR_AFTER_HELP: &str = "\ +Examples: + mb doctor + mb doctor --fix + +`--fix` may expose `mb`, install the managed service, and start it when the +current configuration is valid."; + +pub(crate) const LOGS_LONG_ABOUT: &str = "\ +Read the managed service log at `~/.memory_bank/logs/server.log`. + +Use this when you want to inspect recent server activity or keep watching new +log lines as they are written."; + +pub(crate) const LOGS_AFTER_HELP: &str = "\ +Examples: + mb logs + mb logs --follow"; + +pub(crate) const NAMESPACE_LONG_ABOUT: &str = "\ +Manage Memory Bank namespaces. + +Namespaces isolate long-term memory state so different projects, teams, or +experiments can keep separate SQLite databases under the same install."; + +pub(crate) const NAMESPACE_AFTER_HELP: &str = "\ +Examples: + mb namespace list + mb namespace create work-project + mb namespace use work-project + mb namespace current + +Notes: + Namespace names are sanitized to letters, numbers, hyphens, and underscores. + `mb namespace use` restarts or starts the managed service if it is installed."; + +pub(crate) const SERVICE_LONG_ABOUT: &str = "\ +Manage the user-scoped Memory Bank background service. + +These commands control the managed server process that runs in the background: +launchd on macOS and systemd --user on Linux."; + +pub(crate) const SERVICE_AFTER_HELP: &str = "\ +Examples: + mb service install + mb service start + mb service status + mb service logs --follow + +Notes: + `mb service start` installs the service definition first if it is missing."; + +pub(crate) const CONFIG_LONG_ABOUT: &str = "\ +Inspect and edit saved Memory Bank settings. + +Configuration is stored in `~/.memory_bank/settings.toml`. Use `show` to print +the saved file, `get` to read one value, and `set` to update a single key."; + +pub(crate) const CONFIG_AFTER_HELP: &str = "\ +Supported keys: + active_namespace + service.port + service.autostart + server.llm_provider + server.llm_model + server.ollama_url + server.encoder_provider + server.fastembed_model + server.history_window_size + server.nearest_neighbor_count + server.local_encoder_url + server.remote_encoder_url + integrations.claude_code.configured + integrations.gemini_cli.configured + integrations.opencode.configured + integrations.openclaw.configured + +Important values and defaults: + Default namespace: default + Default service.port: 3737 + server.llm_provider: anthropic | gemini | open-ai | ollama + server.encoder_provider: fast-embed | local-api | remote-api + +Examples: + mb config show + mb config get server.llm_provider + mb config set service.port 4545 + mb config set server.llm_provider gemini + mb config set --yes server.fastembed_model custom/embed-model"; + +pub(crate) const CONFIG_SET_AFTER_HELP: &str = "\ +Supported keys: + active_namespace + service.port + service.autostart + server.llm_provider + server.llm_model + server.ollama_url + server.encoder_provider + server.fastembed_model + server.history_window_size + server.nearest_neighbor_count + server.local_encoder_url + server.remote_encoder_url + integrations.claude_code.configured + integrations.gemini_cli.configured + integrations.opencode.configured + integrations.openclaw.configured + +Important values: + server.llm_provider: anthropic | gemini | open-ai | ollama + server.encoder_provider: fast-embed | local-api | remote-api + +Examples: + mb config set service.port 4545 + mb config set server.llm_provider gemini + mb config set active_namespace work-project + mb config set server.llm_model \"\" + mb config set --yes server.fastembed_model custom/embed-model + +Use an empty string to clear optional string overrides such as +`server.llm_model` or `server.ollama_url`. + +Changing `server.fastembed_model` requires confirmation because the next server +start will rebuild the vector index and re-encode existing memories for that +namespace. Use `--yes` in automation."; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_help_catalog_mentions_supported_keys_and_examples() { + assert!(CONFIG_AFTER_HELP.contains("server.llm_provider")); + assert!(CONFIG_AFTER_HELP.contains("server.fastembed_model")); + assert!(CONFIG_AFTER_HELP.contains("integrations.openclaw.configured")); + assert!(CONFIG_SET_AFTER_HELP.contains("mb config set service.port 4545")); + assert!(CONFIG_SET_AFTER_HELP.contains("Use `--yes` in automation.")); + } +} diff --git a/memory-bank-cli/src/command_utils.rs b/memory-bank-cli/src/command_utils.rs new file mode 100644 index 0000000..f2a7a6d --- /dev/null +++ b/memory-bank-cli/src/command_utils.rs @@ -0,0 +1,250 @@ +use crate::AppError; +use shlex::try_quote; +#[cfg(test)] +use std::collections::BTreeMap; +use std::io; +#[cfg(test)] +use std::path::PathBuf; +use std::process::Command as ProcessCommand; +#[cfg(test)] +use std::process::Stdio; +#[cfg(test)] +use std::time::Duration; +#[cfg(test)] +use wait_timeout::ChildExt; + +#[derive(Debug)] +pub(crate) struct CommandOutcome { + pub(crate) program: String, + pub(crate) args: Vec, + #[allow(dead_code)] + pub(crate) exit_code: Option, + pub(crate) success: bool, + pub(crate) stdout: String, + pub(crate) stderr: String, +} + +pub(crate) fn current_uid() -> Result { + let outcome = run_command_capture("id", &["-u"])?; + if outcome.success { + Ok(outcome.stdout) + } else { + Err(outcome.into_error()) + } +} + +pub(crate) fn run_command(program: &str, args: &[&str]) -> Result<(), AppError> { + let outcome = run_command_capture(program, args)?; + if outcome.success { + Ok(()) + } else { + Err(outcome.into_error()) + } +} + +pub(crate) fn run_command_capture( + program: &str, + args: &[&str], +) -> Result { + let output = ProcessCommand::new(program) + .args(args) + .output() + .map_err(|error| command_error(program, error))?; + + Ok(command_outcome(program, args, output)) +} + +fn command_error(program: &str, error: io::Error) -> AppError { + if error.kind() == io::ErrorKind::NotFound { + AppError::MissingBinary(program.to_string()) + } else { + AppError::Io(error) + } +} + +fn command_outcome(program: &str, args: &[&str], output: std::process::Output) -> CommandOutcome { + CommandOutcome { + program: program.to_string(), + args: args.iter().map(|value| (*value).to_string()).collect(), + exit_code: output.status.code(), + success: output.status.success(), + stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + } +} + +#[cfg(test)] +#[derive(Debug, Clone, Default)] +pub(crate) struct CommandRunOptions { + cwd: Option, + env: BTreeMap, + remove_env: Vec, + timeout: Option, +} + +#[cfg(test)] +impl CommandRunOptions { + pub(crate) fn with_cwd(mut self, cwd: impl Into) -> Self { + self.cwd = Some(cwd.into()); + self + } + + pub(crate) fn with_env(mut self, key: impl Into, value: impl Into) -> Self { + self.env.insert(key.into(), value.into()); + self + } + + pub(crate) fn with_removed_env(mut self, key: impl Into) -> Self { + self.remove_env.push(key.into()); + self + } + + pub(crate) fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } +} + +#[cfg(test)] +pub(crate) fn run_command_capture_with_options( + program: &str, + args: &[&str], + options: &CommandRunOptions, +) -> Result { + let mut command = ProcessCommand::new(program); + command.args(args); + if let Some(cwd) = &options.cwd { + command.current_dir(cwd); + } + command.stdin(Stdio::null()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + for key in &options.remove_env { + command.env_remove(key); + } + for (key, value) in &options.env { + command.env(key, value); + } + + let mut child = command + .spawn() + .map_err(|error| command_error(program, error))?; + let output = match options.timeout { + Some(timeout) => match child.wait_timeout(timeout).map_err(AppError::Io)? { + Some(_) => child.wait_with_output()?, + None => { + let command_line = format!("{} {}", program, args.join(" ")); + let _ = child.kill(); + let _ = child.wait(); + return Err(AppError::CommandTimedOut(command_line, timeout)); + } + }, + None => child.wait_with_output()?, + }; + + Ok(command_outcome(program, args, output)) +} + +pub(crate) fn shell_escape(value: &str) -> String { + let _ = try_quote(value).expect("shell_quote should reject only invalid shell values"); + format!("'{}'", value.replace('\'', r#"'"'"'"#)) +} + +pub(crate) fn yes_no(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} + +impl CommandOutcome { + pub(crate) fn combined_output(&self) -> String { + if self.stderr.is_empty() { + self.stdout.clone() + } else if self.stdout.is_empty() { + self.stderr.clone() + } else { + format!("{}\n{}", self.stdout, self.stderr) + } + } + + pub(crate) fn into_error(self) -> AppError { + let details = if self.stderr.is_empty() { + self.stdout + } else if self.stdout.is_empty() { + self.stderr + } else { + format!("{}\n{}", self.stderr, self.stdout) + }; + AppError::CommandFailed(format!("{} {}", self.program, self.args.join(" ")), details) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_escape_handles_embedded_single_quotes() { + assert_eq!( + shell_escape("it's complicated"), + r#"'it'"'"'s complicated'"# + ); + } + + #[test] + fn combined_output_and_into_error_preserve_stdout_and_stderr() { + let outcome = CommandOutcome { + program: "tool".to_string(), + args: vec!["do".to_string(), "thing".to_string()], + exit_code: Some(1), + success: false, + stdout: "stdout".to_string(), + stderr: "stderr".to_string(), + }; + + assert_eq!(outcome.combined_output(), "stdout\nstderr"); + assert_eq!( + outcome.into_error().to_string(), + "command `tool do thing` failed: stderr\nstdout" + ); + } + + #[test] + fn run_command_capture_reports_missing_binary() { + let error = run_command_capture("memory-bank-cli-test-binary-that-does-not-exist", &[]) + .expect_err("missing binary"); + + assert!(matches!(error, AppError::MissingBinary(_))); + } + + #[test] + fn run_command_capture_with_options_applies_cwd_and_env() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let expected_cwd = std::fs::canonicalize(temp.path()).expect("canonical cwd"); + let outcome = run_command_capture_with_options( + "sh", + &["-c", "printf '%s|%s' \"$PWD\" \"$SPECIAL_VALUE\""], + &CommandRunOptions::default() + .with_cwd(temp.path()) + .with_env("SPECIAL_VALUE", "configured") + .with_timeout(Duration::from_secs(2)), + ) + .expect("run command"); + + assert!(outcome.success); + assert_eq!( + outcome.stdout, + format!("{}|configured", expected_cwd.display()) + ); + } + + #[test] + fn run_command_capture_with_options_times_out() { + let error = run_command_capture_with_options( + "sh", + &["-c", "sleep 5"], + &CommandRunOptions::default().with_timeout(Duration::from_millis(100)), + ) + .expect_err("command should time out"); + + assert!(matches!(error, AppError::CommandTimedOut(_, _))); + } +} diff --git a/memory-bank-cli/src/config.rs b/memory-bank-cli/src/config.rs new file mode 100644 index 0000000..518e52c --- /dev/null +++ b/memory-bank-cli/src/config.rs @@ -0,0 +1,541 @@ +use crate::AppError; +use crate::agents::AgentKind; +use crate::constants::{DEFAULT_HISTORY_WINDOW_SIZE, DEFAULT_NEAREST_NEIGHBOR_COUNT}; +use crate::domain::{ + EncoderProviderId, ProviderId, integration_configured, set_integration_configured, +}; +use crate::models::default_model_for_provider; +use memory_bank_app::{ + AppSettings, DEFAULT_FASTEMBED_MODEL, DEFAULT_NAMESPACE_NAME, DEFAULT_OLLAMA_URL, + IntegrationsSettings, Namespace, ServerSettings, ServiceSettings, +}; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConfigKey { + SchemaVersion, + ActiveNamespace, + ServicePort, + ServiceAutostart, + ServerLlmProvider, + ServerLlmModel, + ServerOllamaUrl, + ServerEncoderProvider, + ServerFastembedModel, + ServerHistoryWindowSize, + ServerNearestNeighborCount, + ServerLocalEncoderUrl, + ServerRemoteEncoderUrl, + IntegrationConfigured(AgentKind), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FastEmbedReindexChange { + pub(crate) previous_model: String, + pub(crate) new_model: String, +} + +impl FromStr for ConfigKey { + type Err = AppError; + + fn from_str(value: &str) -> Result { + match value { + "schema_version" => Ok(Self::SchemaVersion), + "active_namespace" => Ok(Self::ActiveNamespace), + "service.port" => Ok(Self::ServicePort), + "service.autostart" => Ok(Self::ServiceAutostart), + "server.llm_provider" => Ok(Self::ServerLlmProvider), + "server.llm_model" => Ok(Self::ServerLlmModel), + "server.ollama_url" => Ok(Self::ServerOllamaUrl), + "server.encoder_provider" => Ok(Self::ServerEncoderProvider), + "server.fastembed_model" => Ok(Self::ServerFastembedModel), + "server.history_window_size" => Ok(Self::ServerHistoryWindowSize), + "server.nearest_neighbor_count" => Ok(Self::ServerNearestNeighborCount), + "server.local_encoder_url" => Ok(Self::ServerLocalEncoderUrl), + "server.remote_encoder_url" => Ok(Self::ServerRemoteEncoderUrl), + "integrations.claude_code.configured" => { + Ok(Self::IntegrationConfigured(AgentKind::ClaudeCode)) + } + "integrations.gemini_cli.configured" => { + Ok(Self::IntegrationConfigured(AgentKind::GeminiCli)) + } + "integrations.opencode.configured" => { + Ok(Self::IntegrationConfigured(AgentKind::OpenCode)) + } + "integrations.openclaw.configured" => { + Ok(Self::IntegrationConfigured(AgentKind::OpenClaw)) + } + _ => Err(AppError::InvalidConfigKey(value.to_string())), + } + } +} + +pub(crate) fn get_config_value(settings: &AppSettings, key: &str) -> Result { + match key.parse::()? { + ConfigKey::SchemaVersion => Ok(settings.schema_version.to_string()), + ConfigKey::ActiveNamespace => Ok(settings.active_namespace().to_string()), + ConfigKey::ServicePort => Ok(settings.resolved_port().to_string()), + ConfigKey::ServiceAutostart => Ok(settings.resolved_autostart().to_string()), + ConfigKey::ServerLlmProvider => Ok(llm_provider_value(settings).to_string()), + ConfigKey::ServerLlmModel => Ok(resolved_llm_model(settings)), + ConfigKey::ServerOllamaUrl => Ok(resolved_ollama_url( + settings + .server + .as_ref() + .and_then(|server| server.ollama_url.as_deref()), + )), + ConfigKey::ServerEncoderProvider => Ok(resolved_encoder_provider(settings).to_string()), + ConfigKey::ServerFastembedModel => Ok(settings + .server + .as_ref() + .and_then(|server| server.fastembed_model.clone()) + .unwrap_or_else(|| DEFAULT_FASTEMBED_MODEL.to_string())), + ConfigKey::ServerHistoryWindowSize => Ok(settings + .server + .as_ref() + .and_then(|server| server.history_window_size) + .unwrap_or(DEFAULT_HISTORY_WINDOW_SIZE) + .to_string()), + ConfigKey::ServerNearestNeighborCount => Ok(settings + .server + .as_ref() + .and_then(|server| server.nearest_neighbor_count) + .unwrap_or(DEFAULT_NEAREST_NEIGHBOR_COUNT) + .to_string()), + ConfigKey::ServerLocalEncoderUrl => Ok(settings + .server + .as_ref() + .and_then(|server| server.local_encoder_url.clone()) + .unwrap_or_default()), + ConfigKey::ServerRemoteEncoderUrl => Ok(settings + .server + .as_ref() + .and_then(|server| server.remote_encoder_url.clone()) + .unwrap_or_default()), + ConfigKey::IntegrationConfigured(agent) => { + Ok(integration_configured(settings.integrations.as_ref(), agent).to_string()) + } + } +} + +pub(crate) fn set_config_value( + settings: &mut AppSettings, + key: &str, + value: &str, +) -> Result<(), AppError> { + match key.parse::()? { + ConfigKey::SchemaVersion => return Err(AppError::InvalidConfigKey(key.to_string())), + ConfigKey::ActiveNamespace => { + let namespace = Namespace::new(value); + settings.active_namespace = if namespace.as_ref() == DEFAULT_NAMESPACE_NAME { + None + } else { + Some(namespace.to_string()) + }; + } + ConfigKey::ServicePort => { + let port: u16 = value.trim().parse::().map_err(|error| { + AppError::InvalidConfigValue(key.to_string(), error.to_string()) + })?; + if port == 0 { + return Err(AppError::InvalidConfigValue( + key.to_string(), + "must be between 1 and 65535".to_string(), + )); + } + let mut service = settings.service.clone().unwrap_or_default(); + service.port = (port != memory_bank_app::DEFAULT_PORT).then_some(port); + set_service(settings, service); + } + ConfigKey::ServiceAutostart => { + let autostart = parse_bool(value, key)?; + let mut service = settings.service.clone().unwrap_or_default(); + service.autostart = autostart.then_some(true); + set_service(settings, service); + } + ConfigKey::ServerLlmProvider => { + let provider = validate_llm_provider(value.trim(), key)?; + let mut server = settings.server.clone().unwrap_or_default(); + server.llm_provider = if provider == ProviderId::Anthropic { + None + } else { + Some(provider.as_str().to_string()) + }; + if provider != ProviderId::Ollama { + server.ollama_url = None; + } + set_server(settings, server); + } + ConfigKey::ServerLlmModel => { + let provider = llm_provider_value(settings); + let mut server = settings.server.clone().unwrap_or_default(); + server.llm_model = normalize_optional_string(value).and_then(|value| { + if value == default_model_for_provider(provider) { + None + } else { + Some(value) + } + }); + set_server(settings, server); + } + ConfigKey::ServerOllamaUrl => { + let mut server = settings.server.clone().unwrap_or_default(); + server.ollama_url = normalize_optional_string(value).and_then(|value| { + let normalized = normalize_ollama_url(&value); + if normalized == DEFAULT_OLLAMA_URL { + None + } else { + Some(normalized) + } + }); + set_server(settings, server); + } + ConfigKey::ServerEncoderProvider => { + let provider = validate_encoder_provider(value.trim(), key)?; + let mut server = settings.server.clone().unwrap_or_default(); + server.encoder_provider = if provider == EncoderProviderId::FastEmbed { + None + } else { + Some(provider.as_str().to_string()) + }; + set_server(settings, server); + } + ConfigKey::ServerFastembedModel => { + let mut server = settings.server.clone().unwrap_or_default(); + server.fastembed_model = normalize_optional_string(value).and_then(|value| { + if value == DEFAULT_FASTEMBED_MODEL { + None + } else { + Some(value) + } + }); + set_server(settings, server); + } + ConfigKey::ServerHistoryWindowSize => { + let parsed: u32 = value.trim().parse::().map_err(|error| { + AppError::InvalidConfigValue(key.to_string(), error.to_string()) + })?; + let mut server = settings.server.clone().unwrap_or_default(); + server.history_window_size = (parsed != DEFAULT_HISTORY_WINDOW_SIZE).then_some(parsed); + set_server(settings, server); + } + ConfigKey::ServerNearestNeighborCount => { + let parsed: i32 = value.trim().parse::().map_err(|error| { + AppError::InvalidConfigValue(key.to_string(), error.to_string()) + })?; + if parsed < 1 { + return Err(AppError::InvalidConfigValue( + key.to_string(), + "must be at least 1".to_string(), + )); + } + let mut server = settings.server.clone().unwrap_or_default(); + server.nearest_neighbor_count = + (parsed != DEFAULT_NEAREST_NEIGHBOR_COUNT).then_some(parsed); + set_server(settings, server); + } + ConfigKey::ServerLocalEncoderUrl => { + let mut server = settings.server.clone().unwrap_or_default(); + server.local_encoder_url = normalize_optional_string(value); + set_server(settings, server); + } + ConfigKey::ServerRemoteEncoderUrl => { + let mut server = settings.server.clone().unwrap_or_default(); + server.remote_encoder_url = normalize_optional_string(value); + set_server(settings, server); + } + ConfigKey::IntegrationConfigured(agent) => { + let configured = parse_bool(value, key)?; + let mut integrations = settings.integrations.clone().unwrap_or_default(); + set_integration_configured(&mut integrations, agent, configured); + set_integrations(settings, integrations); + } + } + + Ok(()) +} + +pub(crate) fn llm_provider_value(settings: &AppSettings) -> &str { + llm_provider(settings).as_str() +} + +pub(crate) fn llm_provider(settings: &AppSettings) -> ProviderId { + ProviderId::from_config_value( + settings + .server + .as_ref() + .and_then(|server| server.llm_provider.as_deref()), + ) +} + +pub(crate) fn resolved_encoder_provider(settings: &AppSettings) -> &str { + encoder_provider(settings).as_str() +} + +pub(crate) fn encoder_provider(settings: &AppSettings) -> EncoderProviderId { + EncoderProviderId::from_config_value( + settings + .server + .as_ref() + .and_then(|server| server.encoder_provider.as_deref()), + ) +} + +pub(crate) fn resolved_llm_model(settings: &AppSettings) -> String { + settings + .server + .as_ref() + .and_then(|server| server.llm_model.clone()) + .unwrap_or_else(|| default_model_for_provider(llm_provider_value(settings)).to_string()) +} + +pub(crate) fn resolved_fastembed_model(settings: &AppSettings) -> String { + settings + .server + .as_ref() + .and_then(|server| server.fastembed_model.clone()) + .unwrap_or_else(|| DEFAULT_FASTEMBED_MODEL.to_string()) +} + +pub(crate) fn fastembed_reindex_change( + current: &AppSettings, + updated: &AppSettings, +) -> Option { + if resolved_encoder_provider(updated) != EncoderProviderId::FastEmbed.as_str() { + return None; + } + + let previous_model = resolved_fastembed_model(current); + let new_model = resolved_fastembed_model(updated); + if previous_model == new_model { + None + } else { + Some(FastEmbedReindexChange { + previous_model, + new_model, + }) + } +} + +pub(crate) fn resolved_ollama_url(current: Option<&str>) -> String { + current + .map(normalize_ollama_url) + .unwrap_or_else(|| DEFAULT_OLLAMA_URL.to_string()) +} + +pub(crate) fn normalize_ollama_url(value: &str) -> String { + let trimmed = value.trim().trim_end_matches('/'); + if trimmed.is_empty() { + DEFAULT_OLLAMA_URL.to_string() + } else { + trimmed.to_string() + } +} + +pub(crate) fn normalize_optional_string(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +pub(crate) fn validate_llm_provider(value: &str, key: &str) -> Result { + ProviderId::parse(value, key) +} + +pub(crate) fn validate_encoder_provider( + value: &str, + key: &str, +) -> Result { + EncoderProviderId::parse(value, key) +} + +pub(crate) fn set_service(settings: &mut AppSettings, service: ServiceSettings) { + settings.service = (!service.is_empty()).then_some(service); +} + +pub(crate) fn set_server(settings: &mut AppSettings, server: ServerSettings) { + settings.server = (!server.is_empty()).then_some(server); +} + +pub(crate) fn set_integrations(settings: &mut AppSettings, integrations: IntegrationsSettings) { + settings.integrations = (!integrations.is_empty()).then_some(integrations); +} + +fn parse_bool(value: &str, key: &str) -> Result { + value + .trim() + .parse::() + .map_err(|error| AppError::InvalidConfigValue(key.to_string(), error.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use memory_bank_app::ServerSettings; + + #[test] + fn config_get_uses_provider_specific_default_model() { + let settings = AppSettings { + server: Some(ServerSettings { + llm_provider: Some("gemini".to_string()), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + + let model = get_config_value(&settings, "server.llm_model").expect("model value"); + + assert_eq!(model, memory_bank_app::DEFAULT_GEMINI_MODEL); + } + + #[test] + fn fastembed_reindex_change_detects_saved_model_change() { + let current = AppSettings::default(); + let updated = AppSettings { + server: Some(ServerSettings { + fastembed_model: Some("custom/embed-model".to_string()), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + + assert_eq!( + fastembed_reindex_change(¤t, &updated), + Some(FastEmbedReindexChange { + previous_model: DEFAULT_FASTEMBED_MODEL.to_string(), + new_model: "custom/embed-model".to_string(), + }) + ); + } + + #[test] + fn fastembed_reindex_change_ignores_unchanged_effective_model() { + let current = AppSettings::default(); + let updated = AppSettings { + server: Some(ServerSettings { + fastembed_model: Some(DEFAULT_FASTEMBED_MODEL.to_string()), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + + assert_eq!(fastembed_reindex_change(¤t, &updated), None); + } + + #[test] + fn fastembed_reindex_change_ignores_non_fastembed_provider() { + let current = AppSettings::default(); + let updated = AppSettings { + server: Some(ServerSettings { + encoder_provider: Some("local-api".to_string()), + fastembed_model: Some("custom/embed-model".to_string()), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + + assert_eq!(fastembed_reindex_change(¤t, &updated), None); + } + + #[test] + fn config_set_rejects_invalid_provider_and_zero_port() { + let mut settings = AppSettings::default(); + + let provider_error = set_config_value(&mut settings, "server.llm_provider", "wat") + .expect_err("invalid provider should fail"); + assert!(provider_error.to_string().contains("expected one of")); + + let port_error = set_config_value(&mut settings, "service.port", "0") + .expect_err("zero port should fail"); + assert!(port_error.to_string().contains("1 and 65535")); + } + + #[test] + fn config_set_trims_and_normalizes_model_and_ollama_url() { + let mut settings = AppSettings { + server: Some(ServerSettings { + llm_provider: Some("ollama".to_string()), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + + set_config_value(&mut settings, "server.llm_model", " qwen3 ").expect("set model"); + set_config_value( + &mut settings, + "server.ollama_url", + " http://localhost:11434/ ", + ) + .expect("set ollama url"); + + let server = settings.server.expect("server settings"); + assert_eq!(server.llm_model, None); + assert_eq!(server.ollama_url, None); + } + + #[test] + fn config_set_default_values_clear_overrides_and_sections() { + let mut settings = AppSettings::default(); + + set_config_value(&mut settings, "active_namespace", "work").expect("set namespace"); + set_config_value(&mut settings, "service.port", "4545").expect("set port"); + set_config_value(&mut settings, "service.port", "3737").expect("reset port"); + set_config_value(&mut settings, "active_namespace", " default ").expect("reset namespace"); + + assert_eq!(settings.active_namespace, None); + assert!(settings.service.is_none()); + } + + #[test] + fn config_set_switching_away_from_ollama_clears_saved_ollama_url() { + let mut settings = AppSettings { + server: Some(ServerSettings { + llm_provider: Some("ollama".to_string()), + ollama_url: Some("http://ollama.internal:11434".to_string()), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + + set_config_value(&mut settings, "server.llm_provider", "anthropic").expect("set provider"); + + assert!(settings.server.is_none()); + } + + #[test] + fn config_set_integration_flags_round_trip() { + let mut settings = AppSettings::default(); + + set_config_value(&mut settings, "integrations.openclaw.configured", "true") + .expect("set integration"); + assert_eq!( + get_config_value(&settings, "integrations.openclaw.configured") + .expect("get integration"), + "true" + ); + + set_config_value(&mut settings, "integrations.openclaw.configured", "false") + .expect("unset integration"); + assert_eq!( + get_config_value(&settings, "integrations.openclaw.configured") + .expect("get integration"), + "false" + ); + } + + #[test] + fn config_set_rejects_invalid_bool_and_neighbor_count() { + let mut settings = AppSettings::default(); + + let bool_error = set_config_value(&mut settings, "service.autostart", "sometimes") + .expect_err("invalid bool"); + assert!(bool_error.to_string().contains("provided string was not")); + + let count_error = set_config_value(&mut settings, "server.nearest_neighbor_count", "0") + .expect_err("invalid count"); + assert!(count_error.to_string().contains("at least 1")); + } +} diff --git a/memory-bank-cli/src/constants.rs b/memory-bank-cli/src/constants.rs new file mode 100644 index 0000000..be28ff8 --- /dev/null +++ b/memory-bank-cli/src/constants.rs @@ -0,0 +1,36 @@ +use std::time::Duration; + +pub(crate) const MB_BINARY_NAME: &str = "mb"; +pub(crate) const SERVER_BINARY_NAME: &str = "memory-bank-server"; +pub(crate) const HOOK_BINARY_NAME: &str = "memory-bank-hook"; +pub(crate) const MCP_PROXY_BINARY_NAME: &str = "memory-bank-mcp-proxy"; +pub(crate) const LAUNCHD_LABEL: &str = "com.memory-bank.mb"; +pub(crate) const SYSTEMD_UNIT_NAME: &str = "memory-bank.service"; +pub(crate) const REMOTE_MODEL_CATALOG_URL: &str = "https://raw.githubusercontent.com/feelingsonice/MemoryBank/main/config/setup-model-catalog.json"; +pub(crate) const HEALTH_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); +pub(crate) const HEALTH_POLL_INTERVAL: Duration = Duration::from_secs(2); +pub(crate) const SERVICE_TRANSITION_TIMEOUT: Duration = Duration::from_secs(2); +pub(crate) const SERVICE_TRANSITION_POLL_INTERVAL: Duration = Duration::from_millis(100); +pub(crate) const DEFAULT_HISTORY_WINDOW_SIZE: u32 = 0; +pub(crate) const DEFAULT_NEAREST_NEIGHBOR_COUNT: i32 = 10; +pub(crate) const LOG_TAIL_LINE_COUNT: &str = "200"; +pub(crate) const EMBEDDED_OPENCODE_PLUGIN: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../.opencode/plugins/memory-bank.js" +)); +pub(crate) const EMBEDDED_OPENCLAW_INDEX: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../.openclaw/extensions/memory-bank/index.js" +)); +pub(crate) const EMBEDDED_OPENCLAW_MANIFEST: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../.openclaw/extensions/memory-bank/openclaw.plugin.json" +)); +pub(crate) const EMBEDDED_OPENCLAW_PACKAGE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../.openclaw/extensions/memory-bank/package.json" +)); +pub(crate) const EMBEDDED_MODEL_CATALOG: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../config/setup-model-catalog.json" +)); diff --git a/memory-bank-cli/src/domain.rs b/memory-bank-cli/src/domain.rs new file mode 100644 index 0000000..17f54f9 --- /dev/null +++ b/memory-bank-cli/src/domain.rs @@ -0,0 +1,234 @@ +use crate::AppError; +use crate::agents::AgentKind; +use memory_bank_app::{ + DEFAULT_ANTHROPIC_MODEL, DEFAULT_GEMINI_MODEL, DEFAULT_OLLAMA_MODEL, DEFAULT_OPENAI_MODEL, + IntegrationState, IntegrationsSettings, +}; +use std::fmt; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ProviderId { + Anthropic, + Gemini, + OpenAi, + Ollama, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EncoderProviderId { + FastEmbed, + LocalApi, + RemoteApi, +} + +impl ProviderId { + pub(crate) const ALL: [Self; 4] = [Self::Anthropic, Self::Gemini, Self::OpenAi, Self::Ollama]; + + pub(crate) fn from_config_value(value: Option<&str>) -> Self { + match value { + Some("gemini") => Self::Gemini, + Some("open-ai") => Self::OpenAi, + Some("ollama") => Self::Ollama, + _ => Self::Anthropic, + } + } + + pub(crate) fn parse(value: &str, key: &str) -> Result { + match value { + "anthropic" => Ok(Self::Anthropic), + "gemini" => Ok(Self::Gemini), + "open-ai" => Ok(Self::OpenAi), + "ollama" => Ok(Self::Ollama), + _ => Err(AppError::InvalidConfigValue( + key.to_string(), + format!("expected one of: {}", Self::allowed_values()), + )), + } + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Anthropic => "anthropic", + Self::Gemini => "gemini", + Self::OpenAi => "open-ai", + Self::Ollama => "ollama", + } + } + + pub(crate) fn display_name(self) -> &'static str { + match self { + Self::Anthropic => "Anthropic", + Self::Gemini => "Gemini", + Self::OpenAi => "OpenAI", + Self::Ollama => "Ollama (local)", + } + } + + pub(crate) fn default_model(self) -> &'static str { + match self { + Self::Anthropic => DEFAULT_ANTHROPIC_MODEL, + Self::Gemini => DEFAULT_GEMINI_MODEL, + Self::OpenAi => DEFAULT_OPENAI_MODEL, + Self::Ollama => DEFAULT_OLLAMA_MODEL, + } + } + + pub(crate) fn secret_env_key(self) -> Option<&'static str> { + match self { + Self::Anthropic => Some("ANTHROPIC_API_KEY"), + Self::Gemini => Some("GEMINI_API_KEY"), + Self::OpenAi => Some("OPENAI_API_KEY"), + Self::Ollama => None, + } + } + + pub(crate) fn allowed_values() -> &'static str { + "anthropic, gemini, open-ai, ollama" + } +} + +impl fmt::Display for ProviderId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.display_name()) + } +} + +impl EncoderProviderId { + pub(crate) fn from_config_value(value: Option<&str>) -> Self { + match value { + Some("local-api") => Self::LocalApi, + Some("remote-api") => Self::RemoteApi, + _ => Self::FastEmbed, + } + } + + pub(crate) fn parse(value: &str, key: &str) -> Result { + match value { + "fast-embed" => Ok(Self::FastEmbed), + "local-api" => Ok(Self::LocalApi), + "remote-api" => Ok(Self::RemoteApi), + _ => Err(AppError::InvalidConfigValue( + key.to_string(), + format!("expected one of: {}", Self::allowed_values()), + )), + } + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::FastEmbed => "fast-embed", + Self::LocalApi => "local-api", + Self::RemoteApi => "remote-api", + } + } + + pub(crate) fn allowed_values() -> &'static str { + "fast-embed, local-api, remote-api" + } +} + +impl fmt::Display for EncoderProviderId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +pub(crate) fn integration_state( + integrations: Option<&IntegrationsSettings>, + agent: AgentKind, +) -> Option<&IntegrationState> { + integrations.and_then(|integrations| match agent { + AgentKind::ClaudeCode => integrations.claude_code.as_ref(), + AgentKind::GeminiCli => integrations.gemini_cli.as_ref(), + AgentKind::OpenCode => integrations.opencode.as_ref(), + AgentKind::OpenClaw => integrations.openclaw.as_ref(), + }) +} + +pub(crate) fn integration_configured( + integrations: Option<&IntegrationsSettings>, + agent: AgentKind, +) -> bool { + integration_state(integrations, agent) + .map(|state| state.configured) + .unwrap_or(false) +} + +pub(crate) fn set_integration_configured( + integrations: &mut IntegrationsSettings, + agent: AgentKind, + configured: bool, +) { + let state = Some(IntegrationState { configured }); + match agent { + AgentKind::ClaudeCode => integrations.claude_code = state, + AgentKind::GeminiCli => integrations.gemini_cli = state, + AgentKind::OpenCode => integrations.opencode = state, + AgentKind::OpenClaw => integrations.openclaw = state, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_defaults_and_validation_match_expected_values() { + assert_eq!(ProviderId::from_config_value(None), ProviderId::Anthropic); + assert_eq!( + ProviderId::from_config_value(Some("open-ai")), + ProviderId::OpenAi + ); + assert_eq!( + ProviderId::parse("ollama", "server.llm_provider") + .expect("valid provider") + .default_model(), + DEFAULT_OLLAMA_MODEL + ); + + let error = ProviderId::parse("wat", "server.llm_provider").expect_err("invalid provider"); + assert!(error.to_string().contains(ProviderId::allowed_values())); + } + + #[test] + fn encoder_defaults_and_validation_match_expected_values() { + assert_eq!( + EncoderProviderId::from_config_value(None), + EncoderProviderId::FastEmbed + ); + assert_eq!( + EncoderProviderId::from_config_value(Some("remote-api")), + EncoderProviderId::RemoteApi + ); + assert_eq!( + EncoderProviderId::parse("local-api", "server.encoder_provider") + .expect("valid encoder") + .as_str(), + "local-api" + ); + + let error = EncoderProviderId::parse("wat", "server.encoder_provider") + .expect_err("invalid encoder"); + assert!( + error + .to_string() + .contains(EncoderProviderId::allowed_values()) + ); + } + + #[test] + fn integration_helpers_round_trip_agent_state() { + let mut integrations = IntegrationsSettings::default(); + + set_integration_configured(&mut integrations, AgentKind::OpenClaw, true); + + assert!(integration_configured( + Some(&integrations), + AgentKind::OpenClaw + )); + assert!(!integration_configured( + Some(&integrations), + AgentKind::ClaudeCode + )); + } +} diff --git a/memory-bank-cli/src/error.rs b/memory-bank-cli/src/error.rs new file mode 100644 index 0000000..af475b2 --- /dev/null +++ b/memory-bank-cli/src/error.rs @@ -0,0 +1,37 @@ +use inquire::InquireError; +use memory_bank_app::AppConfigError; +use std::io; +use std::time::Duration; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error(transparent)] + AppConfig(#[from] AppConfigError), + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error("prompt failed: {0}")] + Prompt(#[from] InquireError), + #[error("setup was canceled")] + SetupCanceled, + #[error("unsupported platform '{0}'")] + UnsupportedPlatform(String), + #[error("command `{0}` failed: {1}")] + CommandFailed(String, String), + #[error("command `{0}` timed out after {1:?}")] + CommandTimedOut(String, Duration), + #[error("required binary `{0}` was not found")] + MissingBinary(String), + #[error("missing required provider secret `{0}` in ~/.memory_bank/secrets.env")] + MissingProviderSecret(&'static str), + #[error("health check failed: {0}")] + Health(String), + #[error("invalid config key `{0}`")] + InvalidConfigKey(String), + #[error("invalid config value for `{0}`: {1}")] + InvalidConfigValue(String, String), + #[error("{0}")] + Message(String), +} diff --git a/memory-bank-cli/src/json_config.rs b/memory-bank-cli/src/json_config.rs new file mode 100644 index 0000000..fbcfe17 --- /dev/null +++ b/memory-bank-cli/src/json_config.rs @@ -0,0 +1,221 @@ +use crate::AppError; +use chrono::Local; +use jsonc_parser::{ParseOptions, parse_to_serde_value}; +use memory_bank_app::{AppPaths, write_json_file}; +use serde_json::{Map, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub(crate) fn load_json_config(path: &Path) -> Result { + if !path.exists() { + return Ok(Value::Object(Map::new())); + } + + let contents = fs::read_to_string(path)?; + parse_json_config(strip_utf8_bom(&contents), path) +} + +pub(crate) fn write_json_config_with_backups( + paths: &AppPaths, + original_path: &Path, + value: &Value, +) -> Result<(), AppError> { + if original_path.exists() { + backup_existing_file(paths, original_path)?; + } else if let Some(parent) = original_path.parent() { + fs::create_dir_all(parent)?; + } + + write_json_file(original_path, value)?; + Ok(()) +} + +pub(crate) fn ensure_object(value: &mut Value) { + if !value.is_object() { + *value = Value::Object(Map::new()); + } +} + +pub(crate) fn object_mut(value: &mut Value) -> Result<&mut Map, AppError> { + value + .as_object_mut() + .ok_or_else(|| AppError::Message("expected JSON object".to_string())) +} + +pub(crate) fn array_mut(value: &mut Value) -> Result<&mut Vec, AppError> { + value + .as_array_mut() + .ok_or_else(|| AppError::Message("expected JSON array".to_string())) +} + +fn parse_json_config(contents: &str, path: &Path) -> Result { + if contents.trim().is_empty() { + return Ok(Value::Object(Map::new())); + } + + match serde_json::from_str(contents) { + Ok(value) => Ok(value), + Err(strict_error) => match parse_to_serde_value(contents, &jsonc_parse_options()) { + Ok(Some(value)) => Ok(value), + Ok(None) => Ok(Value::Object(Map::new())), + Err(relaxed_error) => Err(AppError::Message(format!( + "failed to parse {}: {} (also failed with JSONC parser: {})", + path.display(), + strict_error, + relaxed_error + ))), + }, + } +} + +fn strip_utf8_bom(contents: &str) -> &str { + contents.strip_prefix('\u{feff}').unwrap_or(contents) +} + +fn backup_existing_file(paths: &AppPaths, original_path: &Path) -> Result<(), AppError> { + let timestamp = Local::now().format("%Y%m%d%H%M%S").to_string(); + let relative = original_path + .strip_prefix(Path::new("/")) + .unwrap_or(original_path); + let central_backup = paths.backups_dir.join(timestamp).join(relative); + if let Some(parent) = central_backup.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(original_path, ¢ral_backup)?; + + let sibling_backup = PathBuf::from(format!("{}.mb_backup", original_path.display())); + if let Some(parent) = sibling_backup.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(original_path, sibling_backup)?; + Ok(()) +} + +fn jsonc_parse_options() -> ParseOptions { + ParseOptions { + allow_comments: true, + allow_loose_object_property_names: false, + allow_trailing_commas: true, + allow_missing_commas: false, + allow_single_quoted_strings: false, + allow_hexadecimal_numbers: false, + allow_unary_plus_numbers: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + use tempfile::TempDir; + + #[test] + fn load_json_config_accepts_comments_and_trailing_commas() { + let temp = TempDir::new().expect("tempdir"); + let config_path = temp.path().join("settings.json"); + fs::write( + &config_path, + r#"{ + // comment + "hooks": { + "Stop": [ + { + "hooks": [ + { "command": "echo hi", }, + ], + }, + ], + }, +} +"#, + ) + .expect("write config"); + + let value = load_json_config(&config_path).expect("load config"); + assert_eq!( + value["hooks"]["Stop"][0]["hooks"][0]["command"], + Value::String("echo hi".to_string()) + ); + } + + #[test] + fn load_json_config_accepts_utf8_bom() { + let temp = TempDir::new().expect("tempdir"); + let config_path = temp.path().join("bom.json"); + fs::write(&config_path, "\u{feff}{\"ok\":true}").expect("write config"); + + let value = load_json_config(&config_path).expect("load config"); + + assert_eq!(value["ok"], Value::Bool(true)); + } + + #[test] + fn load_json_config_reports_path_on_parse_failure() { + let temp = TempDir::new().expect("tempdir"); + let config_path = temp.path().join("broken.json"); + fs::write(&config_path, "{ nope").expect("write broken config"); + + let error = load_json_config(&config_path).expect_err("expected parse failure"); + let message = error.to_string(); + assert!(message.contains("broken.json")); + assert!(message.contains("failed to parse")); + } + + #[test] + fn write_json_config_with_backups_creates_sibling_and_central_backups() { + fn collect_files(root: &Path, files: &mut Vec) { + for entry in fs::read_dir(root).expect("read dir") { + let entry = entry.expect("dir entry"); + let path = entry.path(); + if path.is_dir() { + collect_files(&path, files); + } else { + files.push(path); + } + } + } + + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let config_path = temp.path().join("configs/agent.json"); + fs::create_dir_all(config_path.parent().expect("parent")).expect("config dir"); + fs::write(&config_path, r#"{"stale":true}"#).expect("write original config"); + + write_json_config_with_backups(&paths, &config_path, &serde_json::json!({ "ok": true })) + .expect("write config"); + + assert_eq!( + fs::read_to_string(config_path.with_extension("json.mb_backup")) + .expect("sibling backup"), + r#"{"stale":true}"# + ); + + let mut backup_files = Vec::new(); + collect_files(&paths.backups_dir, &mut backup_files); + assert!( + backup_files + .iter() + .any(|path| path.file_name().and_then(|value| value.to_str()) == Some("agent.json")), + "expected a centralized backup for agent.json" + ); + } + + #[test] + fn load_json_config_missing_file_returns_empty_object() { + let temp = TempDir::new().expect("tempdir"); + let value = load_json_config(&temp.path().join("missing.json")).expect("load config"); + + assert_eq!(value, Value::Object(Map::new())); + } + + #[test] + fn object_and_array_helpers_error_on_wrong_shape() { + let mut value = Value::Bool(true); + let mut array = Value::Object(Map::new()); + + ensure_object(&mut value); + assert!(value.is_object()); + assert!(object_mut(&mut array).is_ok()); + assert!(array_mut(&mut array).is_err()); + } +} diff --git a/memory-bank-cli/src/lib.rs b/memory-bank-cli/src/lib.rs index cf0959b..37a8360 100644 --- a/memory-bank-cli/src/lib.rs +++ b/memory-bank-cli/src/lib.rs @@ -1,1758 +1,39 @@ +mod agents; +mod assets; mod cli; +mod cli_help; +mod command_utils; +mod config; +mod constants; +mod domain; +mod error; +mod json_config; +mod models; +mod operations; +mod output; +#[cfg(test)] +mod real_binary_tests; +mod service; +mod setup; -use chrono::Local; use clap::Parser; -use cli::{Cli, Command, ConfigCommand, InternalCommand, NamespaceCommand, ServiceCommand}; -use dialoguer::{Confirm, Input, MultiSelect, Password, Select}; -use memory_bank_app::{ - AppConfigError, AppPaths, AppSettings, DEFAULT_ANTHROPIC_MODEL, DEFAULT_FASTEMBED_MODEL, - DEFAULT_GEMINI_MODEL, DEFAULT_NAMESPACE_NAME, DEFAULT_OLLAMA_MODEL, DEFAULT_OPENAI_MODEL, - DEFAULT_PORT, IntegrationState, IntegrationsSettings, Namespace, SETTINGS_SCHEMA_VERSION, - SecretStore, default_server_url, env_key_for_provider, write_json_file, -}; -use serde::Deserialize; -use serde_json::{Map, Value, json}; -use std::collections::BTreeMap; -use std::fs; -use std::io; -use std::os::unix::fs::PermissionsExt; -use std::os::unix::process::CommandExt; -use std::path::{Path, PathBuf}; -use std::process::{Command as ProcessCommand, Stdio}; -use thiserror::Error; +use cli::{Cli, Command, InternalCommand}; -const MB_BINARY_NAME: &str = "mb"; -const SERVER_BINARY_NAME: &str = "memory-bank-server"; -const HOOK_BINARY_NAME: &str = "memory-bank-hook"; -const MCP_PROXY_BINARY_NAME: &str = "memory-bank-mcp-proxy"; -const LAUNCHD_LABEL: &str = "com.memory-bank.mb"; -const SYSTEMD_UNIT_NAME: &str = "memory-bank.service"; +pub use error::AppError; pub fn run() -> Result<(), AppError> { let cli = Cli::parse(); match cli.command { - Command::Setup => run_setup(), - Command::Status => run_status(), - Command::Doctor { fix } => run_doctor(fix), - Command::Logs { follow } => run_logs(follow), - Command::Namespace { command } => run_namespace(command), - Command::Service { command } => run_service(command), - Command::Config { command } => run_config(command), + Command::Setup => setup::run_setup(), + Command::Status => operations::run_status(), + Command::Doctor { fix } => operations::run_doctor(fix), + Command::Logs { follow } => operations::run_logs(follow), + Command::Namespace { command } => operations::run_namespace(command), + Command::Service { command } => operations::run_service(command), + Command::Config { command } => operations::run_config(command), Command::Internal { command } => match command { - InternalCommand::RunServer => run_internal_server(), + InternalCommand::RunServer => operations::run_internal_server(), + InternalCommand::BootstrapInstall => operations::run_internal_bootstrap_install(), }, } } - -#[derive(Debug, Error)] -pub enum AppError { - #[error(transparent)] - AppConfig(#[from] AppConfigError), - #[error(transparent)] - Io(#[from] io::Error), - #[error(transparent)] - Json(#[from] serde_json::Error), - #[error("prompt failed: {0}")] - Prompt(#[from] dialoguer::Error), - #[error("unsupported platform '{0}'")] - UnsupportedPlatform(String), - #[error("command `{0}` failed: {1}")] - CommandFailed(String, String), - #[error("required binary `{0}` was not found")] - MissingBinary(String), - #[error("missing required provider secret `{0}` in ~/.memory_bank/secrets.env")] - MissingProviderSecret(&'static str), - #[error("health check failed: {0}")] - Health(String), - #[error("invalid config key `{0}`")] - InvalidConfigKey(String), - #[error("invalid config value for `{0}`: {1}")] - InvalidConfigValue(String, String), - #[error("{0}")] - Message(String), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum AgentKind { - ClaudeCode, - GeminiCli, - OpenCode, - OpenClaw, -} - -impl AgentKind { - fn all() -> [Self; 4] { - [ - Self::ClaudeCode, - Self::GeminiCli, - Self::OpenCode, - Self::OpenClaw, - ] - } - - fn command_name(self) -> &'static str { - match self { - Self::ClaudeCode => "claude", - Self::GeminiCli => "gemini", - Self::OpenCode => "opencode", - Self::OpenClaw => "openclaw", - } - } - - fn display_name(self) -> &'static str { - match self { - Self::ClaudeCode => "Claude Code", - Self::GeminiCli => "Gemini CLI", - Self::OpenCode => "OpenCode", - Self::OpenClaw => "OpenClaw", - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ManagedPlatform { - MacOs, - Linux, -} - -impl ManagedPlatform { - fn detect() -> Result { - match std::env::consts::OS { - "macos" => Ok(Self::MacOs), - "linux" => Ok(Self::Linux), - other => Err(AppError::UnsupportedPlatform(other.to_string())), - } - } -} - -#[derive(Debug, Deserialize)] -struct HealthCheck { - ok: bool, - namespace: String, - port: u16, - llm_provider: String, - encoder_provider: String, - version: String, -} - -#[derive(Debug)] -struct ServiceStatus { - installed: bool, - active: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ServerLaunchSpec { - program: PathBuf, - args: Vec, - env: BTreeMap, - remove_env: Vec<&'static str>, -} - -fn run_setup() -> Result<(), AppError> { - let paths = AppPaths::from_system()?; - paths.ensure_base_dirs()?; - materialize_install_artifacts(&paths)?; - ensure_path_entry(&paths)?; - - let mut settings = AppSettings::load(&paths)?; - let mut secrets = SecretStore::load(&paths)?; - let detected_agents = detect_installed_agents(); - - let namespace = prompt_namespace(settings.active_namespace())?; - let provider = prompt_provider( - settings - .server - .as_ref() - .and_then(|server| server.llm_provider.clone()) - .as_deref(), - )?; - let model = prompt_model( - &provider, - settings - .server - .as_ref() - .and_then(|server| server.llm_model.clone()) - .as_deref(), - )?; - let autostart = prompt_autostart( - settings - .service - .as_ref() - .and_then(|service| service.autostart), - )?; - let selected_agents = prompt_agents(&detected_agents)?; - - configure_provider_secret(&provider, &mut secrets)?; - update_settings_for_setup( - &mut settings, - namespace.clone(), - &provider, - &model, - autostart, - &selected_agents, - ); - settings.save(&paths)?; - secrets.save(&paths)?; - - configure_selected_agents(&paths, &settings, &selected_agents)?; - install_service(&paths, &settings)?; - start_service(&paths)?; - - let health = fetch_health(&settings)?; - println!( - "Memory Bank is ready on {} using namespace `{}` and provider `{}`.", - default_server_url(&settings), - health.namespace, - health.llm_provider - ); - Ok(()) -} - -fn run_status() -> Result<(), AppError> { - let paths = AppPaths::from_system()?; - let settings = AppSettings::load(&paths)?; - let service = service_status(&paths)?; - - println!("Memory Bank"); - println!(" Namespace: {}", settings.active_namespace()); - println!(" Port: {}", settings.resolved_port()); - println!(" Service installed: {}", yes_no(service.installed)); - println!(" Service active: {}", yes_no(service.active)); - - if let Some(server) = settings.server.as_ref() { - if let Some(provider) = server.llm_provider.as_deref() { - println!(" Provider: {provider}"); - } - if let Some(model) = server.llm_model.as_deref() { - println!(" Model: {model}"); - } - } - - if let Ok(health) = fetch_health(&settings) { - println!(" Health: {}", yes_no(health.ok)); - println!(" Health namespace: {}", health.namespace); - println!(" Health port: {}", health.port); - println!(" Health encoder: {}", health.encoder_provider); - println!(" Health version: {}", health.version); - } else { - println!(" Health: unavailable"); - } - - for agent in AgentKind::all() { - let configured = integration_configured(&settings, agent); - println!(" {}: {}", agent.display_name(), yes_no(configured)); - } - - Ok(()) -} - -fn run_doctor(fix: bool) -> Result<(), AppError> { - let paths = AppPaths::from_system()?; - let mut settings = AppSettings::load(&paths)?; - let mut issues = Vec::new(); - - if !paths.settings_file.exists() { - issues.push("settings.json is missing".to_string()); - } - if !paths.binary_path(MB_BINARY_NAME).exists() { - issues.push("mb is not installed under ~/.memory_bank/bin".to_string()); - } - for binary in [SERVER_BINARY_NAME, HOOK_BINARY_NAME, MCP_PROXY_BINARY_NAME] { - if !paths.binary_path(binary).exists() { - issues.push(format!("{binary} is missing from ~/.memory_bank/bin")); - } - } - - if let Some(provider) = settings - .server - .as_ref() - .and_then(|server| server.llm_provider.as_deref()) - && let Some(env_key) = env_key_for_provider(provider) - { - let secrets = SecretStore::load(&paths)?; - if secrets.get(env_key).is_none() { - issues.push(format!("missing {env_key} in ~/.memory_bank/secrets.env")); - } - } - - let service = service_status(&paths)?; - if !service.installed { - issues.push("managed service is not installed".to_string()); - } else if !service.active { - issues.push("managed service is not active".to_string()); - } - - if fetch_health(&settings).is_err() { - issues.push("health check to /healthz failed".to_string()); - } - - if fix { - paths.ensure_base_dirs()?; - materialize_install_artifacts(&paths)?; - ensure_path_entry(&paths)?; - if !service.installed { - install_service(&paths, &settings)?; - } - if service.installed && !service.active { - start_service(&paths)?; - } - settings = AppSettings::load(&paths)?; - issues.retain(|issue| !issue.contains("managed service is not installed")); - } - - if issues.is_empty() { - println!("Memory Bank doctor found no issues."); - } else { - println!("Memory Bank doctor found issues:"); - for issue in &issues { - println!(" - {issue}"); - } - } - - if fix { - let health = fetch_health(&settings)?; - println!( - "Post-fix health is ok on {} for namespace `{}`.", - default_server_url(&settings), - health.namespace - ); - } - - Ok(()) -} - -fn run_logs(follow: bool) -> Result<(), AppError> { - let paths = AppPaths::from_system()?; - tail_log_file(&paths.log_file, follow) -} - -fn run_namespace(command: NamespaceCommand) -> Result<(), AppError> { - let paths = AppPaths::from_system()?; - let mut settings = AppSettings::load(&paths)?; - - match command { - NamespaceCommand::List => { - paths.ensure_base_dirs()?; - let mut namespaces = list_namespaces(&paths)?; - let active = settings.active_namespace(); - if !namespaces.contains(&active) { - namespaces.push(active); - namespaces.sort(); - namespaces.dedup(); - } - - for namespace in namespaces { - let suffix = if namespace == settings.active_namespace() { - " (active)" - } else { - "" - }; - println!("{}{}", namespace, suffix); - } - Ok(()) - } - NamespaceCommand::Create { name } => { - let namespace = Namespace::new(name); - paths.ensure_namespace_dir(&namespace)?; - println!("Created namespace `{namespace}`."); - Ok(()) - } - NamespaceCommand::Use { name } => { - let namespace = Namespace::new(name); - paths.ensure_namespace_dir(&namespace)?; - settings.active_namespace = if namespace.as_ref() == DEFAULT_NAMESPACE_NAME { - None - } else { - Some(namespace.to_string()) - }; - settings.save(&paths)?; - - let status = service_status(&paths)?; - if status.installed { - restart_service(&paths)?; - } - - println!("Active namespace is now `{namespace}`."); - Ok(()) - } - NamespaceCommand::Current => { - println!("{}", settings.active_namespace()); - Ok(()) - } - } -} - -fn run_service(command: ServiceCommand) -> Result<(), AppError> { - let paths = AppPaths::from_system()?; - let settings = AppSettings::load(&paths)?; - - match command { - ServiceCommand::Install => install_service(&paths, &settings), - ServiceCommand::Start => start_service(&paths), - ServiceCommand::Stop => stop_service(&paths), - ServiceCommand::Restart => restart_service(&paths), - ServiceCommand::Status => { - let status = service_status(&paths)?; - println!("Installed: {}", yes_no(status.installed)); - println!("Active: {}", yes_no(status.active)); - Ok(()) - } - ServiceCommand::Logs { follow } => tail_log_file(&paths.log_file, follow), - } -} - -fn run_config(command: ConfigCommand) -> Result<(), AppError> { - let paths = AppPaths::from_system()?; - let mut settings = AppSettings::load(&paths)?; - - match command { - ConfigCommand::Show => { - let rendered = serde_json::to_string_pretty(&settings)?; - println!("{rendered}"); - Ok(()) - } - ConfigCommand::Get { key } => { - let value = get_config_value(&settings, &key)?; - println!("{value}"); - Ok(()) - } - ConfigCommand::Set { key, value } => { - set_config_value(&mut settings, &key, &value)?; - settings.save(&paths)?; - println!("Updated `{key}`."); - Ok(()) - } - } -} - -fn run_internal_server() -> Result<(), AppError> { - let paths = AppPaths::from_system()?; - let settings = AppSettings::load(&paths)?; - let secrets = SecretStore::load(&paths)?; - let spec = build_server_launch_spec(&paths, &settings, &secrets)?; - - let mut command = ProcessCommand::new(&spec.program); - command.args(&spec.args); - for key in &spec.remove_env { - command.env_remove(key); - } - for (key, value) in &spec.env { - command.env(key, value); - } - - Err(AppError::Io(command.exec())) -} - -fn prompt_namespace(current: Namespace) -> Result { - let input: String = Input::new() - .with_prompt("Active namespace") - .default(current.to_string()) - .interact_text()?; - Ok(Namespace::new(input)) -} - -fn prompt_provider(current: Option<&str>) -> Result { - let choices = ["anthropic", "gemini", "open-ai", "ollama"]; - let default_index = current - .and_then(|value| choices.iter().position(|choice| choice == &value)) - .unwrap_or(0); - let selection = Select::new() - .with_prompt("LLM provider") - .items(&choices) - .default(default_index) - .interact()?; - Ok(choices[selection].to_string()) -} - -fn prompt_model(provider: &str, current: Option<&str>) -> Result { - let default_model = current - .map(str::to_owned) - .unwrap_or_else(|| default_model_for_provider(provider).to_string()); - let input: String = Input::new() - .with_prompt(format!("Model for {provider}")) - .default(default_model) - .interact_text()?; - Ok(input) -} - -fn prompt_autostart(current: Option) -> Result { - Confirm::new() - .with_prompt("Start Memory Bank automatically on login") - .default(current.unwrap_or(true)) - .interact() - .map_err(AppError::from) -} - -fn prompt_agents(detected: &[AgentKind]) -> Result, AppError> { - if detected.is_empty() { - return Ok(Vec::new()); - } - - let labels: Vec<&str> = detected.iter().map(|agent| agent.display_name()).collect(); - let defaults = vec![true; labels.len()]; - let selected = MultiSelect::new() - .with_prompt("Configure these detected agents") - .items(&labels) - .defaults(&defaults) - .interact()?; - - Ok(selected.into_iter().map(|index| detected[index]).collect()) -} - -fn configure_provider_secret(provider: &str, secrets: &mut SecretStore) -> Result<(), AppError> { - let Some(secret_key) = env_key_for_provider(provider) else { - return Ok(()); - }; - - let env_value = std::env::var(secret_key).ok(); - let stored_value = secrets.get(secret_key).map(str::to_owned); - - match (stored_value.as_deref(), env_value.as_deref()) { - (None, Some(env_value)) => { - let import = Confirm::new() - .with_prompt(format!( - "Use the existing {secret_key} from your current environment for Memory Bank?" - )) - .default(true) - .interact()?; - if import { - secrets.set(secret_key, env_value); - return Ok(()); - } - } - (Some(stored_value), Some(env_value)) if stored_value != env_value => { - let replace = Confirm::new() - .with_prompt(format!( - "A different {secret_key} is already stored. Replace it with the current environment value?" - )) - .default(false) - .interact()?; - if replace { - secrets.set(secret_key, env_value); - return Ok(()); - } - } - (Some(_), _) => return Ok(()), - (None, None) => {} - } - - let entered = Password::new() - .with_prompt(format!("Enter {secret_key} for Memory Bank")) - .allow_empty_password(false) - .interact()?; - if entered.trim().is_empty() { - return Err(AppError::MissingProviderSecret(secret_key)); - } - secrets.set(secret_key, entered); - Ok(()) -} - -fn update_settings_for_setup( - settings: &mut AppSettings, - namespace: Namespace, - provider: &str, - model: &str, - autostart: bool, - selected_agents: &[AgentKind], -) { - settings.schema_version = SETTINGS_SCHEMA_VERSION; - settings.active_namespace = if namespace.as_ref() == DEFAULT_NAMESPACE_NAME { - None - } else { - Some(namespace.to_string()) - }; - - let mut service = settings.service.clone().unwrap_or_default(); - service.autostart = autostart.then_some(true); - if service.is_empty() { - settings.service = None; - } else { - settings.service = Some(service); - } - - let mut server = settings.server.clone().unwrap_or_default(); - server.llm_provider = if provider == "anthropic" { - None - } else { - Some(provider.to_string()) - }; - let default_model = default_model_for_provider(provider); - server.llm_model = if model == default_model { - None - } else { - Some(model.to_string()) - }; - if server.is_empty() { - settings.server = None; - } else { - settings.server = Some(server); - } - - settings.integrations = Some(IntegrationsSettings { - claude_code: Some(IntegrationState { - configured: selected_agents.contains(&AgentKind::ClaudeCode), - }), - gemini_cli: Some(IntegrationState { - configured: selected_agents.contains(&AgentKind::GeminiCli), - }), - opencode: Some(IntegrationState { - configured: selected_agents.contains(&AgentKind::OpenCode), - }), - openclaw: Some(IntegrationState { - configured: selected_agents.contains(&AgentKind::OpenClaw), - }), - }); -} - -fn detect_installed_agents() -> Vec { - AgentKind::all() - .into_iter() - .filter(|agent| find_on_path(agent.command_name()).is_some()) - .collect() -} - -fn find_on_path(binary: &str) -> Option { - let path_var = std::env::var_os("PATH")?; - for entry in std::env::split_paths(&path_var) { - let candidate = entry.join(binary); - if candidate.exists() { - return Some(candidate); - } - } - None -} - -fn materialize_install_artifacts(paths: &AppPaths) -> Result<(), AppError> { - paths.ensure_base_dirs()?; - let current_exe = std::env::current_exe()?; - copy_if_needed(¤t_exe, &paths.binary_path(MB_BINARY_NAME))?; - - let executable_dir = current_exe.parent().ok_or_else(|| { - AppError::Message("failed to resolve current executable directory".to_string()) - })?; - for binary in [SERVER_BINARY_NAME, HOOK_BINARY_NAME, MCP_PROXY_BINARY_NAME] { - let source = executable_dir.join(binary); - let target = paths.binary_path(binary); - if source.exists() { - copy_if_needed(&source, &target)?; - } else if !target.exists() { - return Err(AppError::MissingBinary(binary.to_string())); - } - } - - install_assets(paths)?; - Ok(()) -} - -fn install_assets(paths: &AppPaths) -> Result<(), AppError> { - let opencode_target = paths - .integrations_dir - .join("opencode") - .join("memory-bank.js"); - let openclaw_target = paths.integrations_dir.join("openclaw").join("memory-bank"); - - if opencode_target.exists() && openclaw_target.exists() { - return Ok(()); - } - - let repo_root = find_repo_root().ok_or_else(|| { - AppError::Message( - "failed to locate repo assets for OpenCode/OpenClaw integration install".to_string(), - ) - })?; - let opencode_source = repo_root.join(".opencode/plugins/memory-bank.js"); - let openclaw_source = repo_root.join(".openclaw/extensions/memory-bank"); - - if !opencode_source.exists() || !openclaw_source.exists() { - return Err(AppError::Message( - "repo asset sources for OpenCode/OpenClaw are missing".to_string(), - )); - } - - copy_if_needed(&opencode_source, &opencode_target)?; - copy_dir_recursive(&openclaw_source, &openclaw_target)?; - Ok(()) -} - -fn find_repo_root() -> Option { - let mut candidates = Vec::new(); - if let Ok(current_dir) = std::env::current_dir() { - candidates.push(current_dir); - } - - let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .map(Path::to_path_buf); - if let Some(root) = workspace_root { - candidates.push(root); - } - - for candidate in candidates { - let mut current = Some(candidate.as_path()); - while let Some(path) = current { - if path.join(".opencode/plugins/memory-bank.js").exists() - && path.join(".openclaw/extensions/memory-bank").exists() - { - return Some(path.to_path_buf()); - } - current = path.parent(); - } - } - - None -} - -fn copy_if_needed(source: &Path, target: &Path) -> Result<(), AppError> { - if let Some(parent) = target.parent() { - fs::create_dir_all(parent)?; - } - - if source == target { - return Ok(()); - } - - fs::copy(source, target)?; - let permissions = fs::Permissions::from_mode(0o755); - fs::set_permissions(target, permissions)?; - - Ok(()) -} - -fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), AppError> { - fs::create_dir_all(target)?; - for entry in fs::read_dir(source)? { - let entry = entry?; - let source_path = entry.path(); - let target_path = target.join(entry.file_name()); - if source_path.is_dir() { - copy_dir_recursive(&source_path, &target_path)?; - } else { - copy_if_needed(&source_path, &target_path)?; - } - } - Ok(()) -} - -fn ensure_path_entry(paths: &AppPaths) -> Result<(), AppError> { - let shell = std::env::var("SHELL").unwrap_or_default(); - let target_rc = if shell.ends_with("zsh") { - paths.home_dir.join(".zshrc") - } else if shell.ends_with("bash") { - paths.home_dir.join(".bashrc") - } else { - paths.home_dir.join(".profile") - }; - let export_line = r#"export PATH="$HOME/.memory_bank/bin:$PATH""#; - let existing = fs::read_to_string(&target_rc).unwrap_or_default(); - if existing.contains(".memory_bank/bin") { - return Ok(()); - } - - let mut updated = existing; - if !updated.ends_with('\n') && !updated.is_empty() { - updated.push('\n'); - } - updated.push_str("# Memory Bank\n"); - updated.push_str(export_line); - updated.push('\n'); - fs::write(target_rc, updated)?; - Ok(()) -} - -fn configure_selected_agents( - paths: &AppPaths, - settings: &AppSettings, - selected_agents: &[AgentKind], -) -> Result<(), AppError> { - let server_url = default_server_url(settings); - for agent in selected_agents { - match agent { - AgentKind::ClaudeCode => configure_claude(paths, &server_url)?, - AgentKind::GeminiCli => configure_gemini(paths, &server_url)?, - AgentKind::OpenCode => configure_opencode(paths, &server_url)?, - AgentKind::OpenClaw => configure_openclaw(paths, &server_url)?, - } - } - Ok(()) -} - -fn configure_claude(paths: &AppPaths, server_url: &str) -> Result<(), AppError> { - run_command( - "claude", - &[ - "mcp", - "add", - "--transport", - "http", - "--scope", - "user", - "memory-bank", - &format!("{server_url}/mcp"), - ], - )?; - - let settings_path = paths.home_dir.join(".claude/settings.json"); - let mut root = load_json_config(&settings_path)?; - let events = ["UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"]; - for event in events { - let command = format!( - "{} --agent claude-code --event {} --server-url {}", - paths.binary_path(HOOK_BINARY_NAME).display(), - event, - server_url - ); - upsert_claude_hook(&mut root, event, &command)?; - } - write_json_config_with_backups(paths, &settings_path, &root) -} - -fn configure_gemini(paths: &AppPaths, server_url: &str) -> Result<(), AppError> { - run_command( - "gemini", - &[ - "mcp", - "add", - "--scope", - "user", - "--transport", - "http", - "memory-bank", - &format!("{server_url}/mcp"), - ], - )?; - - let settings_path = paths.home_dir.join(".gemini/settings.json"); - let mut root = load_json_config(&settings_path)?; - ensure_object(&mut root); - object_mut(&mut root)? - .entry("mcpServers".to_string()) - .or_insert_with(|| json!({})); - object_mut( - object_mut(&mut root)? - .get_mut("mcpServers") - .expect("mcpServers"), - )? - .insert( - "memory-bank".to_string(), - json!({ "httpUrl": format!("{server_url}/mcp") }), - ); - - let hook_events = [ - ("BeforeAgent", "*"), - ("BeforeTool", ".*"), - ("AfterTool", ".*"), - ("AfterAgent", "*"), - ]; - for (event, matcher) in hook_events { - let command = format!( - "{} --agent gemini-cli --event {} --server-url {}", - paths.binary_path(HOOK_BINARY_NAME).display(), - event, - server_url - ); - upsert_gemini_hook(&mut root, event, matcher, &command)?; - } - write_json_config_with_backups(paths, &settings_path, &root) -} - -fn configure_opencode(paths: &AppPaths, server_url: &str) -> Result<(), AppError> { - let plugin_target = paths - .home_dir - .join(".config/opencode/plugins/memory-bank.js"); - copy_if_needed( - &paths.integrations_dir.join("opencode/memory-bank.js"), - &plugin_target, - )?; - - let settings_path = paths.home_dir.join(".config/opencode/opencode.json"); - let mut root = load_json_config(&settings_path)?; - ensure_object(&mut root); - object_mut(&mut root)? - .entry("mcp".to_string()) - .or_insert_with(|| json!({})); - object_mut(object_mut(&mut root)?.get_mut("mcp").expect("mcp"))?.insert( - "memory-bank".to_string(), - json!({ - "type": "remote", - "url": format!("{server_url}/mcp"), - "enabled": true - }), - ); - write_json_config_with_backups(paths, &settings_path, &root) -} - -fn configure_openclaw(paths: &AppPaths, server_url: &str) -> Result<(), AppError> { - let extension_path = paths.integrations_dir.join("openclaw/memory-bank"); - run_command( - "openclaw", - &[ - "plugins", - "install", - "-l", - extension_path.to_string_lossy().as_ref(), - ], - )?; - let command_json = format!( - "{{\"command\":\"{}\",\"args\":[\"--server-url\",\"{}\"]}}", - paths.binary_path(MCP_PROXY_BINARY_NAME).display(), - server_url - ); - run_command("openclaw", &["mcp", "set", "memory-bank", &command_json])?; - - let settings_path = paths.home_dir.join(".openclaw/openclaw.json"); - let mut root = load_json_config(&settings_path)?; - ensure_object(&mut root); - { - let root_map = object_mut(&mut root)?; - root_map - .entry("mcp".to_string()) - .or_insert_with(|| json!({})); - root_map - .entry("plugins".to_string()) - .or_insert_with(|| json!({})); - } - let mcp = object_mut(object_mut(&mut root)?.get_mut("mcp").expect("mcp"))?; - mcp.entry("servers".to_string()) - .or_insert_with(|| json!({})); - object_mut(mcp.get_mut("servers").expect("servers"))?.insert( - "memory-bank".to_string(), - json!({ - "command": paths.binary_path(MCP_PROXY_BINARY_NAME), - "args": ["--server-url", server_url] - }), - ); - - let plugins = object_mut(object_mut(&mut root)?.get_mut("plugins").expect("plugins"))?; - plugins - .entry("entries".to_string()) - .or_insert_with(|| json!({})); - object_mut(plugins.get_mut("entries").expect("entries"))?.insert( - "memory-bank".to_string(), - json!({ - "enabled": true, - "config": { - "hookBinary": paths.binary_path(HOOK_BINARY_NAME), - "serverUrl": server_url - } - }), - ); - plugins - .entry("slots".to_string()) - .or_insert_with(|| json!({})); - object_mut(plugins.get_mut("slots").expect("slots"))? - .insert("memory".to_string(), Value::String("none".to_string())); - - write_json_config_with_backups(paths, &settings_path, &root) -} - -fn install_service(paths: &AppPaths, settings: &AppSettings) -> Result<(), AppError> { - match ManagedPlatform::detect()? { - ManagedPlatform::MacOs => install_launchd_service(paths), - ManagedPlatform::Linux => install_systemd_service(paths, settings), - } -} - -fn install_launchd_service(paths: &AppPaths) -> Result<(), AppError> { - let service_path = launchd_service_path(paths); - if let Some(parent) = service_path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(&service_path, render_launchd_plist(paths))?; - Ok(()) -} - -fn install_systemd_service(paths: &AppPaths, settings: &AppSettings) -> Result<(), AppError> { - let service_path = systemd_service_path(paths); - if let Some(parent) = service_path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(&service_path, render_systemd_unit(paths))?; - run_command("systemctl", &["--user", "daemon-reload"])?; - if settings.resolved_autostart() { - run_command("systemctl", &["--user", "enable", SYSTEMD_UNIT_NAME])?; - } else { - let _ = run_command("systemctl", &["--user", "disable", SYSTEMD_UNIT_NAME]); - } - Ok(()) -} - -fn start_service(paths: &AppPaths) -> Result<(), AppError> { - match ManagedPlatform::detect()? { - ManagedPlatform::MacOs => { - let uid = current_uid()?; - let service_path = launchd_service_path(paths); - if !service_path.exists() { - install_launchd_service(paths)?; - } - let domain = format!("gui/{uid}"); - let status = ProcessCommand::new("launchctl") - .args(["print", &format!("{domain}/{LAUNCHD_LABEL}")]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status()?; - if status.success() { - run_command( - "launchctl", - &["kickstart", "-k", &format!("{domain}/{LAUNCHD_LABEL}")], - ) - } else { - run_command( - "launchctl", - &[ - "bootstrap", - &domain, - service_path.to_string_lossy().as_ref(), - ], - ) - } - } - ManagedPlatform::Linux => run_command("systemctl", &["--user", "start", SYSTEMD_UNIT_NAME]), - } -} - -fn stop_service(_paths: &AppPaths) -> Result<(), AppError> { - match ManagedPlatform::detect()? { - ManagedPlatform::MacOs => { - let uid = current_uid()?; - let _ = run_command( - "launchctl", - &["bootout", &format!("gui/{uid}/{LAUNCHD_LABEL}")], - ); - Ok(()) - } - ManagedPlatform::Linux => { - let _ = run_command("systemctl", &["--user", "stop", SYSTEMD_UNIT_NAME]); - Ok(()) - } - } -} - -fn restart_service(_paths: &AppPaths) -> Result<(), AppError> { - match ManagedPlatform::detect()? { - ManagedPlatform::MacOs => { - let uid = current_uid()?; - run_command( - "launchctl", - &["kickstart", "-k", &format!("gui/{uid}/{LAUNCHD_LABEL}")], - ) - } - ManagedPlatform::Linux => { - run_command("systemctl", &["--user", "restart", SYSTEMD_UNIT_NAME]) - } - } -} - -fn service_status(paths: &AppPaths) -> Result { - match ManagedPlatform::detect()? { - ManagedPlatform::MacOs => { - let installed = launchd_service_path(paths).exists(); - let uid = current_uid()?; - let active = ProcessCommand::new("launchctl") - .args(["print", &format!("gui/{uid}/{LAUNCHD_LABEL}")]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status()? - .success(); - Ok(ServiceStatus { installed, active }) - } - ManagedPlatform::Linux => { - let installed = systemd_service_path(paths).exists(); - let active = ProcessCommand::new("systemctl") - .args(["--user", "is-active", "--quiet", SYSTEMD_UNIT_NAME]) - .status()? - .success(); - Ok(ServiceStatus { installed, active }) - } - } -} - -fn fetch_health(settings: &AppSettings) -> Result { - let health_url = format!("{}/healthz", default_server_url(settings)); - let response = ureq::get(&health_url) - .call() - .map_err(|error| AppError::Health(error.to_string()))?; - response - .into_json::() - .map_err(|error| AppError::Health(error.to_string())) -} - -fn tail_log_file(path: &Path, follow: bool) -> Result<(), AppError> { - let mut command = ProcessCommand::new("tail"); - if follow { - command.args(["-n", "200", "-f"]); - } else { - command.args(["-n", "200"]); - } - command.arg(path); - let status = command.status()?; - if status.success() { - Ok(()) - } else { - Err(AppError::CommandFailed( - "tail".to_string(), - format!("tail exited with status {status}"), - )) - } -} - -fn build_server_launch_spec( - paths: &AppPaths, - settings: &AppSettings, - secrets: &SecretStore, -) -> Result { - let program = if paths.binary_path(SERVER_BINARY_NAME).exists() { - paths.binary_path(SERVER_BINARY_NAME) - } else { - let current_exe = std::env::current_exe()?; - let sibling = current_exe - .parent() - .ok_or_else(|| AppError::MissingBinary(SERVER_BINARY_NAME.to_string()))? - .join(SERVER_BINARY_NAME); - if sibling.exists() { - sibling - } else { - return Err(AppError::MissingBinary(SERVER_BINARY_NAME.to_string())); - } - }; - - let server_settings = settings.server.clone().unwrap_or_default(); - let provider = server_settings - .llm_provider - .clone() - .unwrap_or_else(|| "anthropic".to_string()); - let encoder_provider = server_settings - .encoder_provider - .clone() - .unwrap_or_else(|| "fast-embed".to_string()); - let mut env = BTreeMap::new(); - if let Some(secret_key) = env_key_for_provider(&provider) { - let secret = secrets - .get(secret_key) - .ok_or(AppError::MissingProviderSecret(secret_key))?; - env.insert(secret_key.to_string(), secret.to_string()); - } - - match provider.as_str() { - "ollama" => { - if let Some(model) = server_settings.llm_model { - env.insert("MEMORY_BANK_OLLAMA_MODEL".to_string(), model); - } - } - _ => { - if let Some(model) = server_settings.llm_model { - env.insert("MEMORY_BANK_LLM_MODEL".to_string(), model); - } - } - } - if let Some(model) = server_settings.fastembed_model { - env.insert("MEMORY_BANK_FASTEMBED_MODEL".to_string(), model); - } - if let Some(url) = server_settings.local_encoder_url { - env.insert("MEMORY_BANK_LOCAL_ENCODER_URL".to_string(), url); - } - if let Some(url) = server_settings.remote_encoder_url { - env.insert("MEMORY_BANK_REMOTE_ENCODER_URL".to_string(), url); - } - - Ok(ServerLaunchSpec { - program, - args: vec![ - "--port".to_string(), - settings.resolved_port().to_string(), - "--namespace".to_string(), - settings.active_namespace().to_string(), - "--llm-provider".to_string(), - provider, - "--encoder-provider".to_string(), - encoder_provider, - "--history-window-size".to_string(), - server_settings.history_window_size.unwrap_or(0).to_string(), - "--nearest-neighbor-count".to_string(), - server_settings - .nearest_neighbor_count - .unwrap_or(10) - .to_string(), - ], - env, - remove_env: vec![ - "ANTHROPIC_API_KEY", - "GEMINI_API_KEY", - "OPENAI_API_KEY", - "MEMORY_BANK_LLM_MODEL", - "MEMORY_BANK_FASTEMBED_MODEL", - "MEMORY_BANK_LOCAL_ENCODER_URL", - "MEMORY_BANK_REMOTE_ENCODER_URL", - "MEMORY_BANK_OLLAMA_MODEL", - "MEMORY_BANK_OLLAMA_URL", - ], - }) -} - -fn integration_configured(settings: &AppSettings, agent: AgentKind) -> bool { - settings - .integrations - .as_ref() - .and_then(|integrations| match agent { - AgentKind::ClaudeCode => integrations.claude_code.as_ref(), - AgentKind::GeminiCli => integrations.gemini_cli.as_ref(), - AgentKind::OpenCode => integrations.opencode.as_ref(), - AgentKind::OpenClaw => integrations.openclaw.as_ref(), - }) - .map(|state| state.configured) - .unwrap_or(false) -} - -fn list_namespaces(paths: &AppPaths) -> Result, AppError> { - if !paths.namespaces_dir.exists() { - return Ok(Vec::new()); - } - let mut namespaces = Vec::new(); - for entry in fs::read_dir(&paths.namespaces_dir)? { - let entry = entry?; - if entry.file_type()?.is_dir() { - namespaces.push(Namespace::new(entry.file_name().to_string_lossy())); - } - } - namespaces.sort(); - Ok(namespaces) -} - -fn get_config_value(settings: &AppSettings, key: &str) -> Result { - match key { - "schema_version" => Ok(settings.schema_version.to_string()), - "active_namespace" => Ok(settings.active_namespace().to_string()), - "service.port" => Ok(settings.resolved_port().to_string()), - "service.autostart" => Ok(settings.resolved_autostart().to_string()), - "server.llm_provider" => Ok(settings - .server - .as_ref() - .and_then(|server| server.llm_provider.clone()) - .unwrap_or_else(|| "anthropic".to_string())), - "server.llm_model" => Ok(settings - .server - .as_ref() - .and_then(|server| server.llm_model.clone()) - .unwrap_or_else(|| DEFAULT_ANTHROPIC_MODEL.to_string())), - "server.encoder_provider" => Ok(settings - .server - .as_ref() - .and_then(|server| server.encoder_provider.clone()) - .unwrap_or_else(|| "fast-embed".to_string())), - "server.fastembed_model" => Ok(settings - .server - .as_ref() - .and_then(|server| server.fastembed_model.clone()) - .unwrap_or_else(|| DEFAULT_FASTEMBED_MODEL.to_string())), - "server.history_window_size" => Ok(settings - .server - .as_ref() - .and_then(|server| server.history_window_size) - .unwrap_or(0) - .to_string()), - "server.nearest_neighbor_count" => Ok(settings - .server - .as_ref() - .and_then(|server| server.nearest_neighbor_count) - .unwrap_or(10) - .to_string()), - "server.local_encoder_url" => Ok(settings - .server - .as_ref() - .and_then(|server| server.local_encoder_url.clone()) - .unwrap_or_default()), - "server.remote_encoder_url" => Ok(settings - .server - .as_ref() - .and_then(|server| server.remote_encoder_url.clone()) - .unwrap_or_default()), - key if key.starts_with("integrations.") && key.ends_with(".configured") => { - let agent = config_key_to_agent(key)?; - Ok(integration_configured(settings, agent).to_string()) - } - _ => Err(AppError::InvalidConfigKey(key.to_string())), - } -} - -fn set_config_value(settings: &mut AppSettings, key: &str, value: &str) -> Result<(), AppError> { - match key { - "active_namespace" => { - let namespace = Namespace::new(value); - settings.active_namespace = if namespace.as_ref() == DEFAULT_NAMESPACE_NAME { - None - } else { - Some(namespace.to_string()) - }; - } - "service.port" => { - let port: u16 = value.parse::().map_err(|error| { - AppError::InvalidConfigValue(key.to_string(), error.to_string()) - })?; - let mut service = settings.service.clone().unwrap_or_default(); - service.port = if port == DEFAULT_PORT { - None - } else { - Some(port) - }; - settings.service = if service.is_empty() { - None - } else { - Some(service) - }; - } - "service.autostart" => { - let autostart = parse_bool(value, key)?; - let mut service = settings.service.clone().unwrap_or_default(); - service.autostart = autostart.then_some(true); - settings.service = if service.is_empty() { - None - } else { - Some(service) - }; - } - "server.llm_provider" => { - let mut server = settings.server.clone().unwrap_or_default(); - server.llm_provider = if value == "anthropic" { - None - } else { - Some(value.to_string()) - }; - settings.server = if server.is_empty() { - None - } else { - Some(server) - }; - } - "server.llm_model" => { - let mut server = settings.server.clone().unwrap_or_default(); - server.llm_model = Some(value.to_string()); - settings.server = Some(server); - } - "server.encoder_provider" => { - let mut server = settings.server.clone().unwrap_or_default(); - server.encoder_provider = if value == "fast-embed" { - None - } else { - Some(value.to_string()) - }; - settings.server = if server.is_empty() { - None - } else { - Some(server) - }; - } - "server.fastembed_model" => { - let mut server = settings.server.clone().unwrap_or_default(); - server.fastembed_model = Some(value.to_string()); - settings.server = Some(server); - } - "server.history_window_size" => { - let parsed: u32 = value.parse::().map_err(|error| { - AppError::InvalidConfigValue(key.to_string(), error.to_string()) - })?; - let mut server = settings.server.clone().unwrap_or_default(); - server.history_window_size = if parsed == 0 { None } else { Some(parsed) }; - settings.server = if server.is_empty() { - None - } else { - Some(server) - }; - } - "server.nearest_neighbor_count" => { - let parsed: i32 = value.parse::().map_err(|error| { - AppError::InvalidConfigValue(key.to_string(), error.to_string()) - })?; - if parsed < 1 { - return Err(AppError::InvalidConfigValue( - key.to_string(), - "must be at least 1".to_string(), - )); - } - let mut server = settings.server.clone().unwrap_or_default(); - server.nearest_neighbor_count = if parsed == 10 { None } else { Some(parsed) }; - settings.server = if server.is_empty() { - None - } else { - Some(server) - }; - } - "server.local_encoder_url" => { - let mut server = settings.server.clone().unwrap_or_default(); - server.local_encoder_url = if value.is_empty() { - None - } else { - Some(value.to_string()) - }; - settings.server = if server.is_empty() { - None - } else { - Some(server) - }; - } - "server.remote_encoder_url" => { - let mut server = settings.server.clone().unwrap_or_default(); - server.remote_encoder_url = if value.is_empty() { - None - } else { - Some(value.to_string()) - }; - settings.server = if server.is_empty() { - None - } else { - Some(server) - }; - } - key if key.starts_with("integrations.") && key.ends_with(".configured") => { - let configured = parse_bool(value, key)?; - let agent = config_key_to_agent(key)?; - let integrations = settings - .integrations - .get_or_insert_with(IntegrationsSettings::default); - let state = Some(IntegrationState { configured }); - match agent { - AgentKind::ClaudeCode => integrations.claude_code = state, - AgentKind::GeminiCli => integrations.gemini_cli = state, - AgentKind::OpenCode => integrations.opencode = state, - AgentKind::OpenClaw => integrations.openclaw = state, - } - if integrations.is_empty() { - settings.integrations = None; - } - } - _ => return Err(AppError::InvalidConfigKey(key.to_string())), - } - - Ok(()) -} - -fn parse_bool(value: &str, key: &str) -> Result { - value - .parse::() - .map_err(|error| AppError::InvalidConfigValue(key.to_string(), error.to_string())) -} - -fn config_key_to_agent(key: &str) -> Result { - match key { - "integrations.claude_code.configured" => Ok(AgentKind::ClaudeCode), - "integrations.gemini_cli.configured" => Ok(AgentKind::GeminiCli), - "integrations.opencode.configured" => Ok(AgentKind::OpenCode), - "integrations.openclaw.configured" => Ok(AgentKind::OpenClaw), - _ => Err(AppError::InvalidConfigKey(key.to_string())), - } -} - -fn upsert_claude_hook(root: &mut Value, event: &str, command: &str) -> Result<(), AppError> { - ensure_object(root); - let root_map = object_mut(root)?; - let hooks = root_map - .entry("hooks".to_string()) - .or_insert_with(|| json!({})); - let hooks_map = object_mut(hooks)?; - let groups = hooks_map - .entry(event.to_string()) - .or_insert_with(|| Value::Array(Vec::new())); - let groups_array = array_mut(groups)?; - let marker = format!("--agent claude-code --event {event}"); - let desired = json!({ - "type": "command", - "command": command, - }); - if let Some(existing) = groups_array.iter_mut().find_map(|group| { - group - .get_mut("hooks") - .and_then(Value::as_array_mut) - .and_then(|hooks| { - hooks.iter_mut().find(|hook| { - hook.get("command") - .and_then(Value::as_str) - .map(|value| value.contains(&marker)) - .unwrap_or(false) - }) - }) - }) { - *existing = desired; - } else { - groups_array.push(json!({ "hooks": [desired] })); - } - Ok(()) -} - -fn upsert_gemini_hook( - root: &mut Value, - event: &str, - matcher: &str, - command: &str, -) -> Result<(), AppError> { - ensure_object(root); - let root_map = object_mut(root)?; - let hooks = root_map - .entry("hooks".to_string()) - .or_insert_with(|| json!({})); - let hooks_map = object_mut(hooks)?; - let groups = hooks_map - .entry(event.to_string()) - .or_insert_with(|| Value::Array(Vec::new())); - let groups_array = array_mut(groups)?; - let desired_hook = json!({ - "name": "memory-bank", - "type": "command", - "command": command, - }); - if let Some(existing_group) = groups_array.iter_mut().find(|group| { - group - .get("hooks") - .and_then(Value::as_array) - .map(|hooks| { - hooks.iter().any(|hook| { - hook.get("name") - .and_then(Value::as_str) - .map(|value| value == "memory-bank") - .unwrap_or(false) - }) - }) - .unwrap_or(false) - }) { - if let Some(hooks) = existing_group - .get_mut("hooks") - .and_then(Value::as_array_mut) - { - if let Some(existing_hook) = hooks.iter_mut().find(|hook| { - hook.get("name") - .and_then(Value::as_str) - .map(|value| value == "memory-bank") - .unwrap_or(false) - }) { - *existing_hook = desired_hook; - } - } - } else { - groups_array.push(json!({ - "matcher": matcher, - "sequential": true, - "hooks": [desired_hook], - })); - } - Ok(()) -} - -fn load_json_config(path: &Path) -> Result { - if !path.exists() { - return Ok(Value::Object(Map::new())); - } - let contents = fs::read_to_string(path)?; - let value = serde_json::from_str(&contents)?; - Ok(value) -} - -fn write_json_config_with_backups( - paths: &AppPaths, - original_path: &Path, - value: &Value, -) -> Result<(), AppError> { - if original_path.exists() { - backup_existing_file(paths, original_path)?; - } else if let Some(parent) = original_path.parent() { - fs::create_dir_all(parent)?; - } - write_json_file(original_path, value)?; - Ok(()) -} - -fn backup_existing_file(paths: &AppPaths, original_path: &Path) -> Result<(), AppError> { - let timestamp = Local::now().format("%Y%m%d%H%M%S").to_string(); - let relative = original_path - .strip_prefix(Path::new("/")) - .unwrap_or(original_path); - let central_backup = paths.backups_dir.join(timestamp).join(relative); - if let Some(parent) = central_backup.parent() { - fs::create_dir_all(parent)?; - } - fs::copy(original_path, ¢ral_backup)?; - let sibling_backup = PathBuf::from(format!("{}.mb_backup", original_path.display())); - if let Some(parent) = sibling_backup.parent() { - fs::create_dir_all(parent)?; - } - fs::copy(original_path, sibling_backup)?; - Ok(()) -} - -fn render_launchd_plist(paths: &AppPaths) -> String { - format!( - r#" - - - - Label - {label} - ProgramArguments - - {program} - internal - run-server - - RunAtLoad - - KeepAlive - - StandardOutPath - {log_file} - StandardErrorPath - {log_file} - - -"#, - label = LAUNCHD_LABEL, - program = paths.binary_path(MB_BINARY_NAME).display(), - log_file = paths.log_file.display(), - ) -} - -fn render_systemd_unit(paths: &AppPaths) -> String { - let escaped_mb = shell_escape(paths.binary_path(MB_BINARY_NAME).to_string_lossy().as_ref()); - let escaped_log = shell_escape(paths.log_file.to_string_lossy().as_ref()); - format!( - "[Unit]\nDescription=Memory Bank\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/bin/sh -lc 'exec {escaped_mb} internal run-server >> {escaped_log} 2>&1'\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n" - ) -} - -fn current_uid() -> Result { - let output = ProcessCommand::new("id").arg("-u").output()?; - if !output.status.success() { - return Err(AppError::CommandFailed( - "id -u".to_string(), - String::from_utf8_lossy(&output.stderr).trim().to_string(), - )); - } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - -fn launchd_service_path(paths: &AppPaths) -> PathBuf { - paths - .home_dir - .join("Library/LaunchAgents") - .join(format!("{LAUNCHD_LABEL}.plist")) -} - -fn systemd_service_path(paths: &AppPaths) -> PathBuf { - paths - .home_dir - .join(".config/systemd/user") - .join(SYSTEMD_UNIT_NAME) -} - -fn run_command(program: &str, args: &[&str]) -> Result<(), AppError> { - let output = ProcessCommand::new(program).args(args).output()?; - if output.status.success() { - return Ok(()); - } - - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let details = if stderr.is_empty() { stdout } else { stderr }; - Err(AppError::CommandFailed( - format!("{program} {}", args.join(" ")), - details, - )) -} - -fn shell_escape(value: &str) -> String { - format!("'{}'", value.replace('\'', r#"'"'"'"#)) -} - -fn ensure_object(value: &mut Value) { - if !value.is_object() { - *value = Value::Object(Map::new()); - } -} - -fn object_mut(value: &mut Value) -> Result<&mut Map, AppError> { - value - .as_object_mut() - .ok_or_else(|| AppError::Message("expected JSON object".to_string())) -} - -fn array_mut(value: &mut Value) -> Result<&mut Vec, AppError> { - value - .as_array_mut() - .ok_or_else(|| AppError::Message("expected JSON array".to_string())) -} - -fn default_model_for_provider(provider: &str) -> &'static str { - match provider { - "anthropic" => DEFAULT_ANTHROPIC_MODEL, - "gemini" => DEFAULT_GEMINI_MODEL, - "open-ai" => DEFAULT_OPENAI_MODEL, - "ollama" => DEFAULT_OLLAMA_MODEL, - _ => DEFAULT_ANTHROPIC_MODEL, - } -} - -fn yes_no(value: bool) -> &'static str { - if value { "yes" } else { "no" } -} - -#[cfg(test)] -mod tests { - use super::*; - use memory_bank_app::{ServerSettings, ServiceSettings}; - use tempfile::TempDir; - - #[test] - fn launch_spec_uses_secrets_env_and_strips_ambient_keys() { - let temp = TempDir::new().expect("tempdir"); - let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); - let settings = AppSettings { - schema_version: SETTINGS_SCHEMA_VERSION, - active_namespace: Some("work".to_string()), - service: Some(ServiceSettings { - port: Some(4545), - autostart: Some(true), - }), - server: Some(ServerSettings { - llm_provider: Some("anthropic".to_string()), - llm_model: Some("claude-custom".to_string()), - ..ServerSettings::default() - }), - integrations: None, - }; - let mut secrets = SecretStore::default(); - secrets.set("ANTHROPIC_API_KEY", "from-secrets"); - - fs::create_dir_all(&paths.bin_dir).expect("bin dir"); - fs::write(paths.binary_path(SERVER_BINARY_NAME), "").expect("server placeholder"); - let spec = build_server_launch_spec(&paths, &settings, &secrets).expect("spec"); - - assert_eq!(spec.program, paths.binary_path(SERVER_BINARY_NAME)); - assert!(spec.args.contains(&"--port".to_string())); - assert_eq!( - spec.env.get("ANTHROPIC_API_KEY").map(String::as_str), - Some("from-secrets") - ); - assert!(spec.remove_env.contains(&"ANTHROPIC_API_KEY")); - assert_eq!( - spec.env.get("MEMORY_BANK_LLM_MODEL").map(String::as_str), - Some("claude-custom") - ); - } - - #[test] - fn launchd_plist_points_to_mb_binary_and_log_file() { - let temp = TempDir::new().expect("tempdir"); - let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); - let rendered = render_launchd_plist(&paths); - assert!(rendered.contains("internal")); - assert!(rendered.contains("run-server")); - assert!(rendered.contains(paths.binary_path(MB_BINARY_NAME).to_string_lossy().as_ref())); - assert!(rendered.contains(paths.log_file.to_string_lossy().as_ref())); - } - - #[test] - fn systemd_unit_redirects_to_app_log_file() { - let temp = TempDir::new().expect("tempdir"); - let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); - let rendered = render_systemd_unit(&paths); - assert!(rendered.contains("ExecStart=/bin/sh -lc")); - assert!(rendered.contains("internal run-server")); - assert!(rendered.contains("server.log")); - } -} diff --git a/memory-bank-cli/src/models.rs b/memory-bank-cli/src/models.rs new file mode 100644 index 0000000..3f87cec --- /dev/null +++ b/memory-bank-cli/src/models.rs @@ -0,0 +1,317 @@ +use crate::AppError; +use crate::assets::find_repo_root; +use crate::constants::{EMBEDDED_MODEL_CATALOG, REMOTE_MODEL_CATALOG_URL}; +use crate::domain::ProviderId; +use memory_bank_app::AppPaths; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs; +use std::time::Duration; + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +pub(crate) struct ModelCatalog { + #[serde(default)] + providers: BTreeMap, +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +struct ProviderModelCatalog { + #[serde(default)] + models: Vec, +} + +#[derive(Debug, Deserialize)] +struct OllamaTagsResponse { + #[serde(default)] + models: Vec, +} + +#[derive(Debug, Deserialize)] +struct OllamaTagModel { + name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ModelChoice { + Preset(String), + Current(String), + Custom, +} + +impl ModelChoice { + pub(crate) fn value(&self) -> Option<&str> { + match self { + Self::Preset(model) | Self::Current(model) => Some(model.as_str()), + Self::Custom => None, + } + } +} + +impl fmt::Display for ModelChoice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Preset(model) => f.write_str(model), + Self::Current(model) => write!(f, "Current saved model ({model})"), + Self::Custom => f.write_str("Enter a custom model..."), + } + } +} + +pub(crate) fn default_model_for_provider(provider: &str) -> &'static str { + ProviderId::from_config_value(Some(provider)).default_model() +} + +pub(crate) fn refresh_model_catalog(paths: &AppPaths) -> ModelCatalog { + if let Ok(catalog) = fetch_remote_model_catalog(paths) { + return catalog; + } + + load_local_model_catalog(paths).unwrap_or_default() +} + +pub(crate) fn load_local_model_catalog(paths: &AppPaths) -> Result { + let mut last_error = None; + + if paths.model_catalog_file.exists() { + match fs::read_to_string(&paths.model_catalog_file) + .map_err(AppError::from) + .and_then(|contents| ModelCatalog::from_json(&contents)) + { + Ok(catalog) => return Ok(catalog), + Err(error) => last_error = Some(error), + } + } + + if let Some(local_path) = + find_repo_root().map(|root| root.join("config/setup-model-catalog.json")) + { + match fs::read_to_string(&local_path) + .map_err(AppError::from) + .and_then(|contents| ModelCatalog::from_json(&contents)) + { + Ok(catalog) => return Ok(catalog), + Err(error) => last_error = Some(error), + } + } + + ModelCatalog::from_json(EMBEDDED_MODEL_CATALOG).map_err(|error| last_error.unwrap_or(error)) +} + +pub(crate) fn fetch_ollama_models_for_setup(ollama_url: &str) -> Result, AppError> { + let tags_url = format!("{}/api/tags", ollama_url.trim_end_matches('/')); + let agent = ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(2)) + .timeout_read(Duration::from_secs(3)) + .timeout_write(Duration::from_secs(3)) + .build(); + let response = agent.get(&tags_url).call().map_err(|error| { + AppError::Message(format!("failed to load installed Ollama models: {error}")) + })?; + let tags = response + .into_json::() + .map_err(|error| { + AppError::Message(format!("failed to parse Ollama model list: {error}")) + })?; + + let mut seen = BTreeSet::new(); + let mut models = Vec::new(); + for model in tags.models { + let display = ollama_display_name(&model.name); + if seen.insert(display.clone()) { + models.push(display); + } + } + + Ok(models) +} + +pub(crate) fn model_choices_for_provider( + provider: &str, + current: Option<&str>, + catalog: &ModelCatalog, +) -> Vec { + model_choices_from_values(&catalog.models_for_provider(provider), current) +} + +pub(crate) fn model_choices_from_values(values: &[S], current: Option<&str>) -> Vec +where + S: AsRef, +{ + let mut choices = values + .iter() + .map(|model| ModelChoice::Preset(model.as_ref().to_string())) + .collect::>(); + + if let Some(current_model) = current.filter(|value| !value.is_empty()) + && !values.iter().any(|model| model.as_ref() == current_model) + { + choices.push(ModelChoice::Current(current_model.to_string())); + } + + choices.push(ModelChoice::Custom); + choices +} + +pub(crate) fn ollama_display_name(model: &str) -> String { + model.strip_suffix(":latest").unwrap_or(model).to_string() +} + +impl ModelCatalog { + pub(crate) fn models_for_provider(&self, provider: &str) -> Vec<&str> { + self.providers + .get(provider) + .map(|provider| { + provider + .models + .iter() + .map(String::as_str) + .collect::>() + }) + .unwrap_or_default() + } + + pub(crate) fn from_json(contents: &str) -> Result { + let catalog: Self = serde_json::from_str(contents)?; + Ok(catalog) + } +} + +fn fetch_remote_model_catalog(paths: &AppPaths) -> Result { + let agent = ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(2)) + .timeout_read(Duration::from_secs(3)) + .timeout_write(Duration::from_secs(3)) + .build(); + let response = agent + .get(REMOTE_MODEL_CATALOG_URL) + .call() + .map_err(|error| AppError::Message(format!("failed to fetch model catalog: {error}")))?; + let contents = response.into_string().map_err(|error| { + AppError::Message(format!("failed to read remote model catalog: {error}")) + })?; + let catalog = ModelCatalog::from_json(&contents)?; + paths.ensure_base_dirs()?; + fs::write(&paths.model_catalog_file, format!("{contents}\n"))?; + Ok(catalog) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_model_catalog() -> ModelCatalog { + ModelCatalog::from_json( + r#"{ + "providers": { + "anthropic": { + "models": [ + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-haiku-4-5" + ] + }, + "open-ai": { + "models": [ + "gpt-5.4", + "gpt-5-mini" + ] + } + } +}"#, + ) + .expect("model catalog") + } + + #[test] + fn model_choices_include_current_saved_model_and_custom_fallback() { + let catalog = test_model_catalog(); + let choices = model_choices_for_provider("anthropic", Some("claude-opus-custom"), &catalog); + + assert_eq!( + choices, + vec![ + ModelChoice::Preset("claude-opus-4-6".to_string()), + ModelChoice::Preset("claude-sonnet-4-6".to_string()), + ModelChoice::Preset("claude-haiku-4-5".to_string()), + ModelChoice::Current("claude-opus-custom".to_string()), + ModelChoice::Custom, + ] + ); + } + + #[test] + fn load_local_model_catalog_reads_installed_copy() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + paths.ensure_base_dirs().expect("base dirs"); + fs::write( + &paths.model_catalog_file, + r#"{ + "providers": { + "gemini": { + "models": [ + "gemini-3.1-pro-preview", + "gemini-3-flash-preview" + ] + } + } +}"#, + ) + .expect("write model catalog"); + + let catalog = load_local_model_catalog(&paths).expect("load local catalog"); + + assert_eq!( + catalog.models_for_provider("gemini"), + vec!["gemini-3.1-pro-preview", "gemini-3-flash-preview"] + ); + } + + #[test] + fn load_local_model_catalog_falls_back_when_installed_copy_is_invalid() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + paths.ensure_base_dirs().expect("base dirs"); + fs::write(&paths.model_catalog_file, "{not valid json").expect("write broken catalog"); + + let catalog = load_local_model_catalog(&paths).expect("fallback model catalog"); + + assert!(!catalog.models_for_provider("anthropic").is_empty()); + } + + #[test] + fn empty_model_catalog_still_offers_custom_entry() { + let choices = model_choices_for_provider("ollama", None, &ModelCatalog::default()); + assert_eq!(choices, vec![ModelChoice::Custom]); + } + + #[test] + fn model_choices_do_not_duplicate_current_when_it_is_already_present() { + let choices = model_choices_from_values(&["gpt-5.4", "gpt-5-mini"], Some("gpt-5-mini")); + + assert_eq!( + choices, + vec![ + ModelChoice::Preset("gpt-5.4".to_string()), + ModelChoice::Preset("gpt-5-mini".to_string()), + ModelChoice::Custom, + ] + ); + } + + #[test] + fn default_model_for_unknown_provider_falls_back_to_anthropic() { + assert_eq!( + default_model_for_provider("not-a-provider"), + memory_bank_app::DEFAULT_ANTHROPIC_MODEL + ); + } + + #[test] + fn ollama_display_name_strips_latest_suffix() { + assert_eq!(ollama_display_name("qwen3:latest"), "qwen3"); + assert_eq!(ollama_display_name("qwen3:8b"), "qwen3:8b"); + } +} diff --git a/memory-bank-cli/src/operations.rs b/memory-bank-cli/src/operations.rs new file mode 100644 index 0000000..3f6534c --- /dev/null +++ b/memory-bank-cli/src/operations.rs @@ -0,0 +1,771 @@ +mod render; + +use crate::AppError; +use crate::agents::{AgentKind, integration_configured}; +use crate::assets::{ExposureMode, materialize_and_expose_cli, materialize_install_artifacts}; +use crate::cli::{ConfigCommand, NamespaceCommand, ServiceCommand}; +use crate::command_utils::yes_no; +use crate::config::{ + FastEmbedReindexChange, fastembed_reindex_change, get_config_value, llm_provider_value, + resolved_encoder_provider, resolved_llm_model, resolved_ollama_url, set_config_value, +}; +use crate::output::{ + print_action_start, print_key_value, styled_command, styled_failure, styled_section, + styled_subtle, styled_success, styled_title, styled_warning, +}; +use crate::service::{ + ServiceStatus, build_server_launch_spec, collect_doctor_issues, install_service, + restart_service, service_runtime_summary, service_status, start_service, stop_service, + tail_log_file, +}; +use memory_bank_app::{AppPaths, AppSettings, DEFAULT_NAMESPACE_NAME, Namespace, SecretStore}; +use std::fs; +use std::io::{self, IsTerminal}; +use std::os::unix::process::CommandExt; +use std::process::Command as ProcessCommand; + +use self::render::{ + describe_cli_exposure, describe_install_attempt, describe_start_attempt, print_install_result, + print_live_runtime_section, print_namespace_apply_result, print_service_section, + print_start_or_restart_result, print_stop_result, runtime_health_warning, + runtime_mismatch_fields, +}; + +pub(crate) fn run_status() -> Result<(), AppError> { + let paths = AppPaths::from_system()?; + let settings = AppSettings::load(&paths)?; + let runtime = service_runtime_summary(&paths, &settings)?; + let provider = llm_provider_value(&settings); + let model = resolved_llm_model(&settings); + let encoder = resolved_encoder_provider(&settings); + + println!("{}", styled_title("Memory Bank")); + println!(); + println!("{}", styled_section("Saved configuration")); + print_key_value("Namespace", settings.active_namespace()); + print_key_value("URL", &runtime.url); + print_key_value("Port", settings.resolved_port()); + print_key_value("Provider", provider); + print_key_value("Model", &model); + print_key_value("Encoder", encoder); + if provider == "ollama" { + print_key_value( + "Ollama URL", + resolved_ollama_url( + settings + .server + .as_ref() + .and_then(|server| server.ollama_url.as_deref()), + ), + ); + } + print_key_value("Log file", paths.log_file.display()); + + println!(); + print_service_section(&runtime); + println!(); + print_live_runtime_section(&runtime); + println!(); + println!("{}", styled_section("Integrations")); + for agent in AgentKind::all() { + let configured = integration_configured(&settings, agent); + print_key_value(agent.display_name(), yes_no(configured)); + } + + if let Some(health) = runtime.health.as_ref() { + let mismatch_fields = runtime_mismatch_fields(&settings, provider, encoder, health); + if !mismatch_fields.is_empty() { + println!(); + println!( + "{}", + styled_warning(&format!( + "Warning: Saved configuration differs from the running service for {}. Restart with {} to apply the saved settings.", + mismatch_fields.join(", "), + styled_command("mb service restart") + )) + ); + } + } + if let Some(message) = runtime_health_warning(&runtime) { + println!(); + println!("{}", styled_warning(&format!("Warning: {message}"))); + } + + Ok(()) +} + +pub(crate) fn run_doctor(fix: bool) -> Result<(), AppError> { + let paths = AppPaths::from_system()?; + let mut settings = AppSettings::load(&paths)?; + let issues_before = collect_doctor_issues(&paths, &settings)?; + let mut fix_attempts = Vec::new(); + let issues_after = if fix { + paths.ensure_base_dirs()?; + let exposure = materialize_and_expose_cli(&paths)?; + fix_attempts.push(describe_cli_exposure(exposure.mode)); + + let secrets = SecretStore::load(&paths)?; + let service = service_status(&paths)?; + if !service.installed { + let report = install_service(&paths, &settings)?; + fix_attempts.push(describe_install_attempt(&report)); + } + + let service = service_status(&paths)?; + if !service.active { + if build_server_launch_spec(&paths, &settings, &secrets).is_ok() { + let report = start_service(&paths, &settings)?; + fix_attempts.push(describe_start_attempt(&report)); + } else { + fix_attempts.push( + "Skipped service start because the current configuration is incomplete." + .to_string(), + ); + } + } + + settings = AppSettings::load(&paths)?; + collect_doctor_issues(&paths, &settings)? + } else { + issues_before.clone() + }; + + let doctor_outcome = doctor_outcome(fix, &issues_before, &issues_after); + println!("{}", styled_title("Memory Bank doctor")); + println!(); + + match doctor_outcome { + DoctorOutcome::Healthy => { + println!("{}", styled_success("Success: No issues found.")); + } + DoctorOutcome::IssuesFound => { + println!( + "{}", + styled_warning(&format!( + "Warning: Found {} issue{}.", + issues_before.len(), + plural_suffix(issues_before.len()) + )) + ); + } + DoctorOutcome::FixedCleanly => { + println!( + "{}", + styled_success("Success: Automatic fixes completed and no issues remain.") + ); + } + DoctorOutcome::FixedPartially => { + println!( + "{}", + styled_warning(&format!( + "Warning: Automatic fixes ran, but {} issue{} remain.", + issues_after.len(), + plural_suffix(issues_after.len()) + )) + ); + } + } + + if !issues_before.is_empty() { + println!(); + println!("{}", styled_section("Issues found")); + for issue in &issues_before { + println!(" - {issue}"); + } + } + + if fix && !fix_attempts.is_empty() { + println!(); + println!("{}", styled_section("Fix attempts")); + for attempt in &fix_attempts { + println!(" - {attempt}"); + } + } + + if fix && !issues_after.is_empty() { + println!(); + println!("{}", styled_section("Remaining issues")); + for issue in &issues_after { + println!(" - {issue}"); + } + } + + if matches!( + doctor_outcome, + DoctorOutcome::Healthy | DoctorOutcome::FixedCleanly + ) && let Ok(runtime) = service_runtime_summary(&paths, &settings) + { + println!(); + println!("{}", styled_section("Healthy summary")); + print_key_value("URL", &runtime.url); + print_key_value("Log file", runtime.log_path.display()); + print_key_value("Service active", yes_no(runtime.active)); + match runtime.health.as_ref() { + Some(health) => { + print_key_value("Health", yes_no(health.ok)); + print_key_value("Namespace", &health.namespace); + print_key_value("Port", health.port); + } + None => { + print_key_value("Health", "unavailable"); + } + } + } + + if matches!( + doctor_outcome, + DoctorOutcome::IssuesFound | DoctorOutcome::FixedPartially + ) { + println!(); + println!( + "{}", + styled_warning(&format!( + "Warning: Check {} and {} for more detail.", + styled_command("mb service status"), + styled_command("mb logs -f") + )) + ); + } + + Ok(()) +} + +pub(crate) fn run_logs(follow: bool) -> Result<(), AppError> { + let paths = AppPaths::from_system()?; + tail_log_file(&paths.log_file, follow) +} + +pub(crate) fn run_namespace(command: NamespaceCommand) -> Result<(), AppError> { + let paths = AppPaths::from_system()?; + let mut settings = AppSettings::load(&paths)?; + + match command { + NamespaceCommand::List => { + paths.ensure_base_dirs()?; + let mut namespaces = list_namespaces(&paths)?; + let active = settings.active_namespace(); + if !namespaces.contains(&active) { + namespaces.push(active); + namespaces.sort(); + namespaces.dedup(); + } + + for namespace in namespaces { + let suffix = if namespace == settings.active_namespace() { + " (active)" + } else { + "" + }; + println!("{}{}", namespace, suffix); + } + Ok(()) + } + NamespaceCommand::Create { name } => { + let raw_name = name.trim().to_string(); + let namespace = Namespace::new(&raw_name); + let existed_before = paths.namespace_dir(&namespace).is_dir(); + let directory = paths.ensure_namespace_dir(&namespace)?; + + if existed_before { + println!( + "{}", + styled_warning(&format!( + "Warning: Namespace `{namespace}` already existed." + )) + ); + } else { + println!( + "{}", + styled_success(&format!("Created namespace `{namespace}`.")) + ); + } + print_key_value("Directory", directory.display()); + if raw_name != namespace.to_string() { + println!( + "{}", + styled_warning(&format!( + "Warning: Requested name `{raw_name}` was sanitized to `{namespace}`." + )) + ); + } + Ok(()) + } + NamespaceCommand::Use { name } => { + let raw_name = name.trim().to_string(); + let namespace = Namespace::new(&raw_name); + let directory = paths.ensure_namespace_dir(&namespace)?; + settings.active_namespace = if namespace.as_ref() == DEFAULT_NAMESPACE_NAME { + None + } else { + Some(namespace.to_string()) + }; + settings.save(&paths)?; + + println!( + "{}", + styled_success(&format!("Active namespace is now `{namespace}`.")) + ); + print_key_value("Directory", directory.display()); + if raw_name != namespace.to_string() { + println!( + "{}", + styled_warning(&format!( + "Warning: Requested name `{raw_name}` was sanitized to `{namespace}`." + )) + ); + } + + let status = service_status(&paths)?; + if status.installed { + println!(); + let report = if status.active { + run_action( + "Restarting the managed service to apply the new namespace...", + || restart_service(&paths, &settings), + )? + } else { + run_action( + "Starting the managed service to apply the new namespace...", + || start_service(&paths, &settings), + )? + }; + print_namespace_apply_result(&report); + } else { + println!(); + println!( + "{}", + styled_warning( + "Warning: The managed service is not installed, so this namespace will apply on the next service start." + ) + ); + println!( + "{}", + styled_subtle(&format!( + "Try {} when you are ready.", + styled_command("mb service start") + )) + ); + } + + Ok(()) + } + NamespaceCommand::Current => { + println!("{}", settings.active_namespace()); + Ok(()) + } + } +} + +pub(crate) fn run_service(command: ServiceCommand) -> Result<(), AppError> { + let paths = AppPaths::from_system()?; + let settings = AppSettings::load(&paths)?; + + match command { + ServiceCommand::Install => { + let report = run_action("Installing the managed service definition...", || { + materialize_install_artifacts(&paths)?; + install_service(&paths, &settings) + })?; + print_install_result(&report); + Ok(()) + } + ServiceCommand::Start => { + let report = run_action("Starting Memory Bank service...", || { + materialize_install_artifacts(&paths)?; + start_service(&paths, &settings) + })?; + print_start_or_restart_result(&report); + Ok(()) + } + ServiceCommand::Stop => { + let report = run_action("Stopping Memory Bank service...", || { + stop_service(&paths, &settings) + })?; + print_stop_result(&report); + Ok(()) + } + ServiceCommand::Restart => { + let report = run_action("Restarting Memory Bank service...", || { + materialize_install_artifacts(&paths)?; + restart_service(&paths, &settings) + })?; + print_start_or_restart_result(&report); + Ok(()) + } + ServiceCommand::Status => { + let runtime = service_runtime_summary(&paths, &settings)?; + println!("{}", styled_title("Memory Bank service")); + println!(); + print_service_section(&runtime); + println!(); + print_live_runtime_section(&runtime); + if let Some(message) = runtime_health_warning(&runtime) { + println!(); + println!("{}", styled_warning(&format!("Warning: {message}"))); + } + Ok(()) + } + ServiceCommand::Logs { follow } => tail_log_file(&paths.log_file, follow), + } +} + +pub(crate) fn run_config(command: ConfigCommand) -> Result<(), AppError> { + let paths = AppPaths::from_system()?; + let mut settings = AppSettings::load(&paths)?; + + match command { + ConfigCommand::Show => { + let rendered = settings.to_toml_string()?; + println!("{rendered}"); + Ok(()) + } + ConfigCommand::Get { key } => { + let value = get_config_value(&settings, &key)?; + println!("{value}"); + Ok(()) + } + ConfigCommand::Set { key, value, yes } => { + let previous_settings = settings.clone(); + let previous = get_config_value(&settings, &key)?; + set_config_value(&mut settings, &key, &value)?; + let reindex_change = if key == "server.fastembed_model" { + fastembed_reindex_change(&previous_settings, &settings) + } else { + None + }; + if let Some(change) = reindex_change.as_ref() + && !confirm_fastembed_reindex_change(change, yes)? + { + println!( + "{}", + styled_warning("Config update canceled. No changes were made.") + ); + return Ok(()); + } + settings.save(&paths)?; + let current = get_config_value(&settings, &key)?; + let service = service_status(&paths)?; + + println!("{}", styled_success(&format!("Updated `{key}`."))); + print_key_value("Old value", &previous); + print_key_value("New value", ¤t); + if let Some(change) = reindex_change { + println!( + "{}", + styled_warning(&format!( + "Warning: Changing the FastEmbed model from `{}` to `{}` means the next service start will rebuild the vector index and re-encode existing memories for this namespace.", + change.previous_model, change.new_model + )) + ); + } + if previous == current { + println!( + "{}", + styled_warning("Warning: The saved value is unchanged after normalization.") + ); + } + println!( + "{}", + styled_subtle(&format!( + "Next step: {}", + config_change_hint(&key, &service) + )) + ); + Ok(()) + } + } +} + +fn confirm_fastembed_reindex_change( + change: &FastEmbedReindexChange, + assume_yes: bool, +) -> Result { + if assume_yes { + return Ok(true); + } + + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return Err(AppError::Message( + "Changing the FastEmbed model will rebuild the vector index on startup and re-encode existing memories for this namespace. Re-run this command in an interactive terminal to confirm, or pass `--yes` to accept it.".to_string(), + )); + } + + let prompt = format!( + "Changing the FastEmbed model from `{}` to `{}` will rebuild the vector index on startup and re-encode existing memories for this namespace. Are you sure?", + change.previous_model, change.new_model + ); + let confirmed = inquire::Confirm::new(&prompt) + .with_default(false) + .with_help_message( + "The saved config changes immediately. The vector re-index happens the next time the service starts with this model.", + ) + .prompt_skippable()?; + Ok(matches!(confirmed, Some(true))) +} + +pub(crate) fn run_internal_server() -> Result<(), AppError> { + let paths = AppPaths::from_system()?; + let settings = AppSettings::load(&paths)?; + let secrets = SecretStore::load(&paths)?; + let spec = build_server_launch_spec(&paths, &settings, &secrets)?; + + let mut command = ProcessCommand::new(&spec.program); + command.args(&spec.args); + for key in &spec.remove_env { + command.env_remove(key); + } + for (key, value) in &spec.env { + command.env(key, value); + } + + Err(AppError::Io(command.exec())) +} + +pub(crate) fn run_internal_bootstrap_install() -> Result<(), AppError> { + let paths = AppPaths::from_system()?; + let exposure = materialize_and_expose_cli(&paths)?; + + println!( + "{}", + styled_success(&format!( + "Memory Bank binaries are installed under {}.", + paths.root.display() + )) + ); + match exposure.mode { + ExposureMode::Direct => { + println!( + "{}", + styled_success("`mb` is available directly in this shell.") + ); + } + ExposureMode::Launcher => { + println!( + "{}", + styled_success("A managed `mb` launcher was installed on your current PATH.") + ); + } + ExposureMode::ShellInitFallback => { + println!( + "{}", + styled_warning( + "Warning: Managed shell startup files were updated for future shells." + ) + ); + println!( + "{}", + styled_subtle(&format!( + "Use `{}` in this shell until you start a new terminal.", + paths.binary_path("mb").display() + )) + ); + } + } + + Ok(()) +} + +fn run_action(message: &str, action: F) -> Result +where + F: FnOnce() -> Result, +{ + print_action_start(message)?; + match action() { + Ok(value) => { + println!("{}", styled_success("done")); + Ok(value) + } + Err(error) => { + println!("{}", styled_failure("failed")); + Err(error) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConfigChangeEffect { + RestartRequired, + FutureStartsOnly, + MetadataOnly, +} + +fn config_change_effect(key: &str) -> ConfigChangeEffect { + match key { + "active_namespace" | "service.port" => ConfigChangeEffect::RestartRequired, + "service.autostart" => ConfigChangeEffect::FutureStartsOnly, + key if key.starts_with("server.") => ConfigChangeEffect::RestartRequired, + key if key.starts_with("integrations.") => ConfigChangeEffect::MetadataOnly, + _ => ConfigChangeEffect::MetadataOnly, + } +} + +fn config_change_hint(key: &str, service: &ServiceStatus) -> String { + match config_change_effect(key) { + ConfigChangeEffect::RestartRequired => { + if service.active { + format!( + "Restart the managed service with {} to apply this change to the running server.", + styled_command("mb service restart") + ) + } else if service.installed { + format!( + "Start the managed service with {} to apply this change.", + styled_command("mb service start") + ) + } else { + "This change will apply the next time the managed service starts.".to_string() + } + } + ConfigChangeEffect::FutureStartsOnly => { + "Autostart affects future launches only; it does not change the current running service." + .to_string() + } + ConfigChangeEffect::MetadataOnly => { + "This updates saved metadata only. No service restart is required.".to_string() + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DoctorOutcome { + Healthy, + IssuesFound, + FixedCleanly, + FixedPartially, +} + +fn doctor_outcome(fix: bool, issues_before: &[String], issues_after: &[String]) -> DoctorOutcome { + if fix { + if issues_after.is_empty() { + DoctorOutcome::FixedCleanly + } else { + DoctorOutcome::FixedPartially + } + } else if issues_before.is_empty() { + DoctorOutcome::Healthy + } else { + DoctorOutcome::IssuesFound + } +} + +fn plural_suffix(count: usize) -> &'static str { + if count == 1 { "" } else { "s" } +} + +fn list_namespaces(paths: &AppPaths) -> Result, AppError> { + if !paths.namespaces_dir.exists() { + return Ok(Vec::new()); + } + + let mut namespaces = Vec::new(); + for entry in fs::read_dir(&paths.namespaces_dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + namespaces.push(Namespace::new(entry.file_name().to_string_lossy())); + } + } + namespaces.sort(); + Ok(namespaces) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::HealthCheck; + use tempfile::TempDir; + + #[test] + fn list_namespaces_returns_sorted_directories_only() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + fs::create_dir_all(paths.namespaces_dir.join("zeta")).expect("zeta"); + fs::create_dir_all(paths.namespaces_dir.join("alpha team")).expect("alpha"); + fs::write(paths.namespaces_dir.join("README.txt"), "ignore").expect("file"); + + let namespaces = list_namespaces(&paths).expect("list namespaces"); + + assert_eq!( + namespaces, + vec![Namespace::new("alpha team"), Namespace::new("zeta")] + ); + } + + #[test] + fn runtime_mismatch_fields_reports_changed_runtime_values() { + let settings = AppSettings { + active_namespace: Some("work".to_string()), + service: Some(memory_bank_app::ServiceSettings { + port: Some(4545), + autostart: Some(true), + }), + ..AppSettings::default() + }; + let health = HealthCheck { + ok: true, + namespace: "default".to_string(), + port: 3737, + llm_provider: "gemini".to_string(), + encoder_provider: "remote-api".to_string(), + version: "test".to_string(), + }; + + let fields = runtime_mismatch_fields(&settings, "anthropic", "fast-embed", &health); + + assert_eq!(fields, vec!["namespace", "port", "provider", "encoder"]); + } + + #[test] + fn config_change_hint_depends_on_key_and_service_state() { + let active_service = ServiceStatus { + installed: true, + active: true, + }; + let installed_inactive = ServiceStatus { + installed: true, + active: false, + }; + let missing_service = ServiceStatus { + installed: false, + active: false, + }; + + assert!( + config_change_hint("server.llm_provider", &active_service) + .contains("mb service restart") + ); + assert!( + config_change_hint("service.port", &installed_inactive).contains("mb service start") + ); + assert!( + config_change_hint("active_namespace", &missing_service) + .contains("next time the managed service starts") + ); + assert!( + config_change_hint("service.autostart", &active_service) + .contains("future launches only") + ); + assert!( + config_change_hint("integrations.opencode.configured", &active_service) + .contains("No service restart is required") + ); + } + + #[test] + fn doctor_outcome_distinguishes_clean_and_partial_results() { + let issue = vec!["one issue".to_string()]; + + assert_eq!(doctor_outcome(false, &[], &[]), DoctorOutcome::Healthy); + assert_eq!( + doctor_outcome(false, &issue, &issue), + DoctorOutcome::IssuesFound + ); + assert_eq!( + doctor_outcome(true, &issue, &[]), + DoctorOutcome::FixedCleanly + ); + assert_eq!( + doctor_outcome(true, &issue, &issue), + DoctorOutcome::FixedPartially + ); + } +} diff --git a/memory-bank-cli/src/operations/render.rs b/memory-bank-cli/src/operations/render.rs new file mode 100644 index 0000000..b1d80b8 --- /dev/null +++ b/memory-bank-cli/src/operations/render.rs @@ -0,0 +1,351 @@ +use crate::command_utils::yes_no; +use crate::constants::HEALTH_STARTUP_TIMEOUT; +use crate::output::{ + print_key_value, styled_command, styled_section, styled_subtle, styled_success, styled_warning, +}; +use crate::service::{HealthCheck, ServiceActionKind, ServiceActionReport, ServiceRuntimeSummary}; +use memory_bank_app::AppSettings; +use memory_bank_app::ServerStartupPhase; + +pub(super) fn runtime_mismatch_fields<'a>( + settings: &'a AppSettings, + provider: &'a str, + encoder: &'a str, + health: &'a HealthCheck, +) -> Vec<&'static str> { + let mut fields = Vec::new(); + if health.namespace != settings.active_namespace().to_string() { + fields.push("namespace"); + } + if health.port != settings.resolved_port() { + fields.push("port"); + } + if health.llm_provider != provider { + fields.push("provider"); + } + if health.encoder_provider != encoder { + fields.push("encoder"); + } + fields +} + +pub(super) fn runtime_health_warning(runtime: &ServiceRuntimeSummary) -> Option { + if runtime.active + && runtime.health.is_none() + && let Some(state) = runtime.startup_state.as_ref() + && state.phase == ServerStartupPhase::Reindexing + { + return Some(match state.memory_count { + Some(memory_count) => format!( + "The service process is running, but Memory Bank is not up yet because it is rebuilding the vector index and re-encoding {memory_count} stored memor{}.", + if memory_count == 1 { "y" } else { "ies" } + ), + None => "The service process is running, but Memory Bank is not up yet because it is rebuilding the vector index and re-encoding stored memories.".to_string(), + }); + } + + match (runtime.active, runtime.health.is_some()) { + (true, false) => Some( + "The service manager reports the service as active, but `/healthz` is unavailable. It may still be starting or it may be unhealthy." + .to_string(), + ), + (false, true) => Some( + "The health endpoint responded even though the managed service is not active. Another process may be serving this URL." + .to_string(), + ), + _ => None, + } +} + +pub(super) fn print_service_section(runtime: &ServiceRuntimeSummary) { + println!("{}", styled_section("Managed service")); + print_key_value("Manager", runtime.manager.display_name()); + print_key_value("Definition", runtime.definition_path.display()); + print_key_value("Installed", yes_no(runtime.installed)); + print_key_value("Active", yes_no(runtime.active)); + print_key_value("URL", &runtime.url); + print_key_value("Log file", runtime.log_path.display()); + if let Some(pid) = runtime.pid { + print_key_value("PID", pid); + } +} + +pub(super) fn print_live_runtime_section(runtime: &ServiceRuntimeSummary) { + println!("{}", styled_section("Live runtime")); + match runtime.health.as_ref() { + Some(health) => { + print_key_value("Health", yes_no(health.ok)); + print_key_value("Namespace", &health.namespace); + print_key_value("Port", health.port); + print_key_value("Provider", &health.llm_provider); + print_key_value("Encoder", &health.encoder_provider); + print_key_value("Version", &health.version); + } + None => { + print_key_value("Health", "unavailable"); + if let Some(error) = runtime.health_error.as_ref() { + print_key_value("Detail", error); + } + if let Some(state) = runtime.startup_state.as_ref() { + print_key_value("Startup", startup_phase_label(state)); + } + } + } +} + +fn startup_phase_label(state: &memory_bank_app::ServerStartupState) -> String { + match state.phase { + ServerStartupPhase::Reindexing => match state.memory_count { + Some(memory_count) => format!( + "reindexing {memory_count} stored memor{}", + if memory_count == 1 { "y" } else { "ies" } + ), + None => "reindexing stored memories".to_string(), + }, + } +} + +pub(super) fn print_install_result(report: &ServiceActionReport) { + let message = if report.installed_before { + "Success: Updated the managed service definition." + } else { + "Success: Installed the managed service definition." + }; + println!("{}", styled_success(message)); + print_key_value("Manager", report.manager.display_name()); + print_key_value("Definition", report.definition_path.display()); + print_key_value("Autostart", yes_no(report.autostart)); + print_key_value("Active", yes_no(report.active_after)); + print_key_value("Log file", report.log_path.display()); +} + +pub(super) fn print_start_or_restart_result(report: &ServiceActionReport) { + let message = if !report.active_after { + "Warning: Sent the service request, but the managed service does not appear active yet." + } else { + match report.action { + ServiceActionKind::Restart if report.fell_back_to_start => { + "Success: The service was not running, so restart started it instead." + } + ServiceActionKind::Restart => "Success: Restarted Memory Bank service.", + ServiceActionKind::Start if report.active_before => { + "Success: Memory Bank service was already active and is still running." + } + ServiceActionKind::Start => "Success: Started Memory Bank service.", + _ => "Success: Updated Memory Bank service state.", + } + }; + let rendered = if message.starts_with("Success:") { + styled_success(message) + } else { + styled_warning(message) + }; + println!("{rendered}"); + if report.installed_during_action { + println!( + "{}", + styled_subtle("Installed the managed service definition as part of this command.") + ); + } + + print_key_value("Manager", report.manager.display_name()); + print_key_value("URL", &report.url); + print_key_value("Log file", report.log_path.display()); + if let Some(pid) = report.pid { + print_key_value("PID", pid); + } + + match report.health.as_ref() { + Some(health) => { + print_key_value("Health", yes_no(health.ok)); + print_key_value("Namespace", &health.namespace); + print_key_value("Port", health.port); + print_key_value("Provider", &health.llm_provider); + print_key_value("Encoder", &health.encoder_provider); + print_key_value("Version", &health.version); + } + None if report.active_after => { + print_key_value("Health", "still starting"); + if let Some(error) = report.health_error.as_ref() { + print_key_value("Detail", error); + } + println!( + "{}", + styled_warning(&format!( + "Warning: The service manager reports active, but `/healthz` did not respond within {}s. It may still be starting.", + HEALTH_STARTUP_TIMEOUT.as_secs() + )) + ); + println!( + "{}", + styled_subtle(&format!( + "Try {} or {}.", + styled_command("mb service status"), + styled_command("mb logs -f") + )) + ); + } + None => { + print_key_value("Health", "unavailable"); + if !report.active_after { + println!( + "{}", + styled_subtle(&format!( + "Try {} or {} for more detail.", + styled_command("mb service status"), + styled_command("mb logs -f") + )) + ); + } + } + } +} + +pub(super) fn print_stop_result(report: &ServiceActionReport) { + let message = if !report.installed_before { + "Warning: The managed service is not installed." + } else if !report.active_before { + "Warning: The managed service is already stopped." + } else if report.active_after { + "Warning: Sent a stop request, but the service still appears active." + } else { + "Success: Stopped Memory Bank service." + }; + let rendered = if message.starts_with("Success:") { + styled_success(message) + } else { + styled_warning(message) + }; + println!("{rendered}"); + print_key_value("Manager", report.manager.display_name()); + print_key_value("Definition", report.definition_path.display()); + print_key_value("Installed", yes_no(report.installed_after)); + print_key_value("Active", yes_no(report.active_after)); + print_key_value("Log file", report.log_path.display()); + if report.active_after { + println!( + "{}", + styled_subtle(&format!( + "Try {} or {} for more detail.", + styled_command("mb service status"), + styled_command("mb logs -f") + )) + ); + } +} + +pub(super) fn print_namespace_apply_result(report: &ServiceActionReport) { + let action_label = if !report.active_after { + "Warning: Saved the namespace change, but the managed service does not appear active yet." + } else if report.fell_back_to_start { + "Success: Applied the namespace by starting the managed service." + } else if matches!(report.action, ServiceActionKind::Restart) { + "Success: Applied the namespace by restarting the managed service." + } else { + "Success: Applied the namespace by starting the managed service." + }; + let rendered = if action_label.starts_with("Success:") { + styled_success(action_label) + } else { + styled_warning(action_label) + }; + println!("{rendered}"); + print_key_value("URL", &report.url); + print_key_value("Log file", report.log_path.display()); + if let Some(health) = report.health.as_ref() { + print_key_value("Health", yes_no(health.ok)); + print_key_value("Namespace", &health.namespace); + print_key_value("Port", health.port); + } else if report.active_after { + print_key_value("Health", "still starting"); + if let Some(error) = report.health_error.as_ref() { + print_key_value("Detail", error); + } + } else { + println!( + "{}", + styled_subtle(&format!( + "Try {} or {} for more detail.", + styled_command("mb service status"), + styled_command("mb logs -f") + )) + ); + } +} + +pub(super) fn describe_cli_exposure(mode: crate::assets::ExposureMode) -> String { + match mode { + crate::assets::ExposureMode::Direct => { + "`mb` is available directly in this shell.".to_string() + } + crate::assets::ExposureMode::Launcher => { + "Installed or refreshed the managed `mb` launcher on PATH.".to_string() + } + crate::assets::ExposureMode::ShellInitFallback => { + "Updated managed shell startup files for future shells.".to_string() + } + } +} + +pub(super) fn describe_install_attempt(report: &ServiceActionReport) -> String { + if report.installed_during_action { + format!( + "Installed the managed service definition at {}.", + report.definition_path.display() + ) + } else { + format!( + "Refreshed the managed service definition at {}.", + report.definition_path.display() + ) + } +} + +pub(super) fn describe_start_attempt(report: &ServiceActionReport) -> String { + if report.active_after && report.health.is_some() { + format!( + "Started the managed service and verified health on {}.", + report.url + ) + } else if report.active_after { + format!( + "Started the managed service, but `/healthz` is still unavailable on {}.", + report.url + ) + } else { + "Tried to start the managed service, but it does not appear active yet.".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::{ServiceManager, ServiceRuntimeSummary}; + use std::path::PathBuf; + + #[test] + fn runtime_health_warning_mentions_reindexing_when_startup_state_says_so() { + let runtime = ServiceRuntimeSummary { + manager: ServiceManager::Launchd, + definition_path: PathBuf::from("/tmp/service.plist"), + log_path: PathBuf::from("/tmp/server.log"), + url: "http://127.0.0.1:3737".to_string(), + installed: true, + active: true, + pid: Some(4242), + health: None, + health_error: Some("health check failed".to_string()), + startup_state: Some(memory_bank_app::ServerStartupState { + pid: 4242, + namespace: "default".to_string(), + phase: ServerStartupPhase::Reindexing, + memory_count: Some(12), + }), + }; + + let warning = runtime_health_warning(&runtime).expect("warning"); + + assert!(warning.contains("not up yet")); + assert!(warning.contains("re-encoding 12 stored memories")); + } +} diff --git a/memory-bank-cli/src/output.rs b/memory-bank-cli/src/output.rs new file mode 100644 index 0000000..b066ae0 --- /dev/null +++ b/memory-bank-cli/src/output.rs @@ -0,0 +1,52 @@ +use std::fmt::Display; +use std::io::{self, Write}; + +pub(crate) fn no_color_requested() -> bool { + std::env::var_os("NO_COLOR").is_some() + || matches!(std::env::var("TERM").ok().as_deref(), Some("dumb")) +} + +fn paint(text: &str, code: &str) -> String { + if no_color_requested() { + text.to_string() + } else { + format!("\x1b[{code}m{text}\x1b[0m") + } +} + +pub(crate) fn styled_title(text: &str) -> String { + paint(text, "1;36") +} + +pub(crate) fn styled_command(text: &str) -> String { + paint(text, "1;36") +} + +pub(crate) fn styled_section(text: &str) -> String { + paint(&format!("== {text} =="), "1;34") +} + +pub(crate) fn styled_subtle(text: &str) -> String { + paint(text, "2") +} + +pub(crate) fn styled_success(text: &str) -> String { + paint(text, "1;32") +} + +pub(crate) fn styled_warning(text: &str) -> String { + paint(text, "1;33") +} + +pub(crate) fn styled_failure(text: &str) -> String { + paint(text, "1;31") +} + +pub(crate) fn print_action_start(message: &str) -> io::Result<()> { + print!("{} ", styled_subtle(message)); + io::stdout().flush() +} + +pub(crate) fn print_key_value(label: &str, value: impl Display) { + println!(" {label}: {value}"); +} diff --git a/memory-bank-cli/src/real_binary_tests.rs b/memory-bank-cli/src/real_binary_tests.rs new file mode 100644 index 0000000..47974de --- /dev/null +++ b/memory-bank-cli/src/real_binary_tests.rs @@ -0,0 +1,124 @@ +use crate::AppError; +use crate::assets::{find_on_path, install_embedded_assets}; +use crate::command_utils::{CommandOutcome, CommandRunOptions, run_command_capture_with_options}; +use crate::constants::{HOOK_BINARY_NAME, MCP_PROXY_BINARY_NAME}; +use memory_bank_app::AppPaths; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tempfile::TempDir; + +const REAL_BINARY_TEST_ENV: &str = "MEMORY_BANK_REAL_BIN_TESTS"; +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15); + +pub(crate) struct RealBinaryTestEnv { + _home: TempDir, + _cwd: TempDir, + pub(crate) paths: AppPaths, + cwd: PathBuf, +} + +impl RealBinaryTestEnv { + pub(crate) fn for_binary(binary: &str) -> Option { + if std::env::var(REAL_BINARY_TEST_ENV).ok().as_deref() != Some("1") { + eprintln!( + "skipping real-binary test for `{binary}` because {REAL_BINARY_TEST_ENV}=1 is not set" + ); + return None; + } + + if find_on_path(binary).is_none() { + eprintln!("skipping real-binary test for `{binary}` because it is not installed"); + return None; + } + + Some(Self::new().expect("real binary test environment")) + } + + fn new() -> Result { + let home = TempDir::new()?; + let cwd = TempDir::new()?; + let paths = AppPaths::from_home_dir(home.path().to_path_buf()); + paths.ensure_base_dirs()?; + fs::create_dir_all(home.path().join(".config"))?; + fs::create_dir_all(home.path().join(".local/state"))?; + fs::create_dir_all(home.path().join(".local/share"))?; + + Ok(Self { + paths, + cwd: cwd.path().to_path_buf(), + _home: home, + _cwd: cwd, + }) + } + + pub(crate) fn seed_agent_artifacts(&self) -> Result<(), AppError> { + install_embedded_assets(&self.paths)?; + for binary in [HOOK_BINARY_NAME, MCP_PROXY_BINARY_NAME] { + self.write_stub_binary(&self.paths.binary_path(binary))?; + } + Ok(()) + } + + pub(crate) fn seed_gemini_home(&self) -> Result<(), AppError> { + fs::create_dir_all(self.paths.home_dir.join(".gemini"))?; + Ok(()) + } + + fn write_stub_binary(&self, path: &Path) -> Result<(), AppError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, "#!/bin/sh\nexit 0\n")?; + #[cfg(unix)] + { + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions)?; + } + Ok(()) + } + + pub(crate) fn command_options(&self) -> CommandRunOptions { + CommandRunOptions::default() + .with_cwd(&self.cwd) + .with_env("HOME", self.paths.home_dir.to_string_lossy().to_string()) + .with_env( + "XDG_CONFIG_HOME", + self.paths + .home_dir + .join(".config") + .to_string_lossy() + .to_string(), + ) + .with_env( + "XDG_STATE_HOME", + self.paths + .home_dir + .join(".local/state") + .to_string_lossy() + .to_string(), + ) + .with_env( + "XDG_DATA_HOME", + self.paths + .home_dir + .join(".local/share") + .to_string_lossy() + .to_string(), + ) + .with_env("NO_COLOR", "1") + .with_env("CI", "1") + .with_env("TERM", "dumb") + .with_removed_env("OPENCLAW_STATE_DIR") + .with_removed_env("OPENCLAW_CONFIG_PATH") + .with_removed_env("OPENCLAW_CONTAINER") + .with_timeout(DEFAULT_TIMEOUT) + } + + pub(crate) fn run_cli(&self, program: &str, args: &[&str]) -> Result { + run_command_capture_with_options(program, args, &self.command_options()) + } +} diff --git a/memory-bank-cli/src/service.rs b/memory-bank-cli/src/service.rs new file mode 100644 index 0000000..6ce9c26 --- /dev/null +++ b/memory-bank-cli/src/service.rs @@ -0,0 +1,1026 @@ +mod definitions; +mod launch; + +use crate::AppError; +use crate::command_utils::{current_uid, run_command, run_command_capture}; +use crate::constants::{ + HEALTH_POLL_INTERVAL, HEALTH_STARTUP_TIMEOUT, LAUNCHD_LABEL, LOG_TAIL_LINE_COUNT, + SERVICE_TRANSITION_POLL_INTERVAL, SERVICE_TRANSITION_TIMEOUT, SYSTEMD_UNIT_NAME, +}; +use memory_bank_app::{AppPaths, AppSettings, ServerStartupState, default_server_url}; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command as ProcessCommand, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use self::definitions::{ + install_launchd_service, install_systemd_service, launchd_service_path, systemd_service_path, +}; +#[cfg(test)] +use self::definitions::{render_launchd_plist, render_systemd_unit}; +pub(crate) use self::launch::{build_server_launch_spec, collect_doctor_issues}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ManagedPlatform { + MacOs, + Linux, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ServiceManager { + Launchd, + Systemd, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub(crate) struct HealthCheck { + pub(crate) ok: bool, + pub(crate) namespace: String, + pub(crate) port: u16, + pub(crate) llm_provider: String, + pub(crate) encoder_provider: String, + pub(crate) version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ServiceStatus { + pub(crate) installed: bool, + pub(crate) active: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ServiceRuntimeSummary { + pub(crate) manager: ServiceManager, + pub(crate) definition_path: PathBuf, + pub(crate) log_path: PathBuf, + pub(crate) url: String, + pub(crate) installed: bool, + pub(crate) active: bool, + pub(crate) pid: Option, + pub(crate) health: Option, + pub(crate) health_error: Option, + pub(crate) startup_state: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ServiceActionKind { + Install, + Start, + Restart, + Stop, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ServiceActionReport { + pub(crate) action: ServiceActionKind, + pub(crate) manager: ServiceManager, + pub(crate) definition_path: PathBuf, + pub(crate) log_path: PathBuf, + pub(crate) url: String, + pub(crate) autostart: bool, + pub(crate) installed_before: bool, + pub(crate) active_before: bool, + pub(crate) installed_after: bool, + pub(crate) active_after: bool, + pub(crate) installed_during_action: bool, + pub(crate) fell_back_to_start: bool, + pub(crate) pid: Option, + pub(crate) health: Option, + pub(crate) health_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ServerLaunchSpec { + pub(crate) program: PathBuf, + pub(crate) args: Vec, + pub(crate) env: BTreeMap, + pub(crate) remove_env: Vec<&'static str>, +} + +impl ManagedPlatform { + fn detect() -> Result { + match std::env::consts::OS { + "macos" => Ok(Self::MacOs), + "linux" => Ok(Self::Linux), + other => Err(AppError::UnsupportedPlatform(other.to_string())), + } + } + + fn manager(self) -> ServiceManager { + match self { + Self::MacOs => ServiceManager::Launchd, + Self::Linux => ServiceManager::Systemd, + } + } + + fn definition_path(self, paths: &AppPaths) -> PathBuf { + match self { + Self::MacOs => launchd_service_path(paths), + Self::Linux => systemd_service_path(paths), + } + } +} + +impl ServiceManager { + pub(crate) fn display_name(self) -> &'static str { + match self { + Self::Launchd => "launchd", + Self::Systemd => "systemd --user", + } + } +} + +pub(crate) fn install_service( + paths: &AppPaths, + settings: &AppSettings, +) -> Result { + paths.ensure_base_dirs()?; + let platform = ManagedPlatform::detect()?; + let before = service_status(paths)?; + match platform { + ManagedPlatform::MacOs => install_launchd_service(paths, settings)?, + ManagedPlatform::Linux => install_systemd_service(paths, settings)?, + } + let after = service_status(paths)?; + + Ok(ServiceActionReport { + action: ServiceActionKind::Install, + manager: platform.manager(), + definition_path: platform.definition_path(paths), + log_path: paths.log_file.clone(), + url: default_server_url(settings), + autostart: settings.resolved_autostart(), + installed_before: before.installed, + active_before: before.active, + installed_after: after.installed, + active_after: after.active, + installed_during_action: !before.installed && after.installed, + fell_back_to_start: false, + pid: if after.active { + best_effort_service_pid(paths, platform) + } else { + None + }, + health: None, + health_error: None, + }) +} + +pub(crate) fn start_service( + paths: &AppPaths, + settings: &AppSettings, +) -> Result { + let platform = ManagedPlatform::detect()?; + let before = service_status(paths)?; + let installed_during_action = match platform { + ManagedPlatform::MacOs => { + let uid = current_uid()?; + let service_path = launchd_service_path(paths); + let mut installed_during_action = false; + if !service_path.exists() { + install_launchd_service(paths, settings)?; + installed_during_action = true; + } + let domain = format!("gui/{uid}"); + let status = ProcessCommand::new("launchctl") + .args(["print", &format!("{domain}/{LAUNCHD_LABEL}")]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if status.success() { + run_command( + "launchctl", + &["kickstart", "-k", &format!("{domain}/{LAUNCHD_LABEL}")], + )?; + } else { + run_command( + "launchctl", + &[ + "bootstrap", + &domain, + service_path.to_string_lossy().as_ref(), + ], + )?; + run_command( + "launchctl", + &["kickstart", "-k", &format!("{domain}/{LAUNCHD_LABEL}")], + )?; + } + installed_during_action + } + ManagedPlatform::Linux => { + let mut installed_during_action = false; + if !systemd_service_path(paths).exists() { + install_systemd_service(paths, settings)?; + installed_during_action = true; + } + run_command("systemctl", &["--user", "start", SYSTEMD_UNIT_NAME])?; + installed_during_action + } + }; + + let after = wait_for_service_status( + paths, + true, + SERVICE_TRANSITION_TIMEOUT, + SERVICE_TRANSITION_POLL_INTERVAL, + )?; + let (health, health_error) = wait_for_service_health(settings, after.active); + + Ok(ServiceActionReport { + action: ServiceActionKind::Start, + manager: platform.manager(), + definition_path: platform.definition_path(paths), + log_path: paths.log_file.clone(), + url: default_server_url(settings), + autostart: settings.resolved_autostart(), + installed_before: before.installed, + active_before: before.active, + installed_after: after.installed, + active_after: after.active, + installed_during_action, + fell_back_to_start: false, + pid: if after.active { + best_effort_service_pid(paths, platform) + } else { + None + }, + health, + health_error, + }) +} + +pub(crate) fn stop_service( + paths: &AppPaths, + settings: &AppSettings, +) -> Result { + let platform = ManagedPlatform::detect()?; + let before = service_status(paths)?; + let stop_error = if before.installed && before.active { + match platform { + ManagedPlatform::MacOs => { + let uid = current_uid()?; + run_command( + "launchctl", + &["bootout", &format!("gui/{uid}/{LAUNCHD_LABEL}")], + ) + .err() + } + ManagedPlatform::Linux => { + run_command("systemctl", &["--user", "stop", SYSTEMD_UNIT_NAME]).err() + } + } + } else { + None + }; + let after = wait_for_service_status( + paths, + false, + SERVICE_TRANSITION_TIMEOUT, + SERVICE_TRANSITION_POLL_INTERVAL, + )?; + if let Some(error) = stop_error + && after.active + { + return Err(error); + } + + Ok(ServiceActionReport { + action: ServiceActionKind::Stop, + manager: platform.manager(), + definition_path: platform.definition_path(paths), + log_path: paths.log_file.clone(), + url: default_server_url(settings), + autostart: settings.resolved_autostart(), + installed_before: before.installed, + active_before: before.active, + installed_after: after.installed, + active_after: after.active, + installed_during_action: false, + fell_back_to_start: false, + pid: if after.active { + best_effort_service_pid(paths, platform) + } else { + None + }, + health: None, + health_error: None, + }) +} + +pub(crate) fn restart_service( + paths: &AppPaths, + settings: &AppSettings, +) -> Result { + let platform = ManagedPlatform::detect()?; + let before = service_status(paths)?; + let before_pid = before + .active + .then(|| best_effort_service_pid(paths, platform)) + .flatten(); + let (installed_during_action, fell_back_to_start) = match platform { + ManagedPlatform::MacOs => { + if before.active { + let uid = current_uid()?; + run_command( + "launchctl", + &["kickstart", "-k", &format!("gui/{uid}/{LAUNCHD_LABEL}")], + )?; + (false, false) + } else { + let report = start_service(paths, settings)?; + return Ok(ServiceActionReport { + action: ServiceActionKind::Restart, + fell_back_to_start: true, + ..report + }); + } + } + ManagedPlatform::Linux => { + if !systemd_service_path(paths).exists() { + let report = start_service(paths, settings)?; + return Ok(ServiceActionReport { + action: ServiceActionKind::Restart, + fell_back_to_start: true, + ..report + }); + } + if before.active { + run_command("systemctl", &["--user", "restart", SYSTEMD_UNIT_NAME])?; + (false, false) + } else { + let report = start_service(paths, settings)?; + return Ok(ServiceActionReport { + action: ServiceActionKind::Restart, + fell_back_to_start: true, + ..report + }); + } + } + }; + let after = if before.active { + wait_for_service_restart( + paths, + platform, + before_pid, + SERVICE_TRANSITION_TIMEOUT, + SERVICE_TRANSITION_POLL_INTERVAL, + )? + } else { + wait_for_service_status( + paths, + true, + SERVICE_TRANSITION_TIMEOUT, + SERVICE_TRANSITION_POLL_INTERVAL, + )? + }; + let (health, health_error) = wait_for_service_health(settings, after.active); + + Ok(ServiceActionReport { + action: ServiceActionKind::Restart, + manager: platform.manager(), + definition_path: platform.definition_path(paths), + log_path: paths.log_file.clone(), + url: default_server_url(settings), + autostart: settings.resolved_autostart(), + installed_before: before.installed, + active_before: before.active, + installed_after: after.installed, + active_after: after.active, + installed_during_action, + fell_back_to_start, + pid: if after.active { + best_effort_service_pid(paths, platform) + } else { + None + }, + health, + health_error, + }) +} + +pub(crate) fn service_status(paths: &AppPaths) -> Result { + match ManagedPlatform::detect()? { + ManagedPlatform::MacOs => { + let installed = launchd_service_path(paths).exists(); + let uid = current_uid()?; + let active = ProcessCommand::new("launchctl") + .args(["print", &format!("gui/{uid}/{LAUNCHD_LABEL}")]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()? + .success(); + Ok(ServiceStatus { installed, active }) + } + ManagedPlatform::Linux => { + let installed = systemd_service_path(paths).exists(); + let active = ProcessCommand::new("systemctl") + .args(["--user", "is-active", "--quiet", SYSTEMD_UNIT_NAME]) + .status()? + .success(); + Ok(ServiceStatus { installed, active }) + } + } +} + +pub(crate) fn service_runtime_summary( + paths: &AppPaths, + settings: &AppSettings, +) -> Result { + let platform = ManagedPlatform::detect()?; + let status = service_status(paths)?; + let pid = if status.active { + best_effort_service_pid(paths, platform) + } else { + None + }; + let health_result = fetch_health(settings); + let (health, health_error) = match health_result { + Ok(health) => (Some(health), None), + Err(error) => (None, Some(error.to_string())), + }; + let startup_state = load_server_startup_state(paths, settings, status.active, pid); + + Ok(ServiceRuntimeSummary { + manager: platform.manager(), + definition_path: platform.definition_path(paths), + log_path: paths.log_file.clone(), + url: default_server_url(settings), + installed: status.installed, + active: status.active, + pid, + health, + health_error, + startup_state, + }) +} + +pub(crate) fn fetch_health(settings: &AppSettings) -> Result { + let health_url = format!("{}/healthz", default_server_url(settings)); + let agent = ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(2)) + .timeout_read(Duration::from_secs(2)) + .timeout_write(Duration::from_secs(2)) + .build(); + let response = agent + .get(&health_url) + .call() + .map_err(|error| AppError::Health(error.to_string()))?; + response + .into_json::() + .map_err(|error| AppError::Health(error.to_string())) +} + +pub(crate) fn wait_for_health( + settings: &AppSettings, + timeout: Duration, + poll_interval: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + + loop { + match fetch_health(settings) { + Ok(health) => return Ok(health), + Err(error) => { + if Instant::now() >= deadline { + return Err(AppError::Health(format!( + "service did not become healthy within {}s: {}", + timeout.as_secs(), + error + ))); + } + } + } + + thread::sleep(poll_interval); + } +} + +fn wait_for_service_status( + paths: &AppPaths, + desired_active: bool, + timeout: Duration, + poll_interval: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + let mut last = service_status(paths)?; + if last.active == desired_active { + return Ok(last); + } + + loop { + if Instant::now() >= deadline { + return Ok(last); + } + + thread::sleep(poll_interval); + last = service_status(paths)?; + if last.active == desired_active { + return Ok(last); + } + } +} + +fn wait_for_service_restart( + paths: &AppPaths, + platform: ManagedPlatform, + before_pid: Option, + timeout: Duration, + poll_interval: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + let mut saw_inactive = false; + let mut observed_follow_up_poll = false; + let mut last = service_status(paths)?; + + loop { + if !last.active { + saw_inactive = true; + } else { + let current_pid = best_effort_service_pid(paths, platform); + let pid_changed = matches!( + (before_pid, current_pid), + (Some(before_pid), Some(current_pid)) if current_pid != before_pid + ); + if saw_inactive || pid_changed || (before_pid.is_none() && observed_follow_up_poll) { + return Ok(last); + } + } + + if Instant::now() >= deadline { + return Ok(last); + } + + thread::sleep(poll_interval); + observed_follow_up_poll = true; + last = service_status(paths)?; + } +} + +fn wait_for_service_health( + settings: &AppSettings, + service_active: bool, +) -> (Option, Option) { + if !service_active { + return (None, None); + } + + match wait_for_health(settings, HEALTH_STARTUP_TIMEOUT, HEALTH_POLL_INTERVAL) { + Ok(health) => (Some(health), None), + Err(error) => (None, Some(error.to_string())), + } +} + +fn best_effort_service_pid(_paths: &AppPaths, platform: ManagedPlatform) -> Option { + match platform { + ManagedPlatform::MacOs => { + let uid = current_uid().ok()?; + let domain = format!("gui/{uid}/{LAUNCHD_LABEL}"); + let outcome = run_command_capture("launchctl", &["print", &domain]).ok()?; + launchctl_pid_from_output(&outcome.combined_output()) + } + ManagedPlatform::Linux => { + let outcome = run_command_capture( + "systemctl", + &[ + "--user", + "show", + SYSTEMD_UNIT_NAME, + "--property", + "MainPID", + "--value", + ], + ) + .ok()?; + systemd_main_pid_from_output(&outcome.combined_output()) + } + } +} + +fn load_server_startup_state( + paths: &AppPaths, + settings: &AppSettings, + service_active: bool, + pid: Option, +) -> Option { + if !service_active { + return None; + } + + let path = paths.server_startup_state_path(&settings.active_namespace()); + let contents = fs::read_to_string(path).ok()?; + let state = serde_json::from_str::(&contents).ok()?; + if state.namespace != settings.active_namespace().to_string() { + return None; + } + + if let Some(pid) = pid + && state.pid != pid + { + return None; + } + + Some(state) +} + +fn launchctl_pid_from_output(output: &str) -> Option { + for line in output.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("pid = ") { + return rest.trim().parse::().ok().filter(|pid| *pid > 0); + } + if let Some((_, rest)) = trimmed.split_once("pid = ") { + return rest.trim().parse::().ok().filter(|pid| *pid > 0); + } + } + None +} + +fn systemd_main_pid_from_output(output: &str) -> Option { + let trimmed = output.trim(); + if trimmed.is_empty() || trimmed == "0" { + None + } else { + trimmed.parse::().ok().filter(|pid| *pid > 0) + } +} + +pub(crate) fn tail_log_file(path: &Path, follow: bool) -> Result<(), AppError> { + if !path.exists() { + if follow { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, "")?; + } else { + return Err(AppError::Message(format!( + "log file does not exist yet: {}. The managed service may not have started yet. Try `mb service status` or `mb service start`.", + path.display() + ))); + } + } + + let mut command = ProcessCommand::new("tail"); + if follow { + command.args(["-n", LOG_TAIL_LINE_COUNT, "-f"]); + } else { + command.args(["-n", LOG_TAIL_LINE_COUNT]); + } + command.arg(path); + let status = command.status()?; + if status.success() { + Ok(()) + } else { + Err(AppError::CommandFailed( + "tail".to_string(), + format!("tail exited with status {status}"), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::{MB_BINARY_NAME, SERVER_BINARY_NAME}; + use memory_bank_app::SecretStore; + use memory_bank_app::{ServerSettings, ServiceSettings}; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use tempfile::TempDir; + + #[cfg(unix)] + fn make_runnable(path: &Path) { + let mut permissions = fs::metadata(path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("set permissions"); + } + + #[test] + fn launch_spec_uses_secrets_env_and_strips_ambient_keys() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings = AppSettings { + schema_version: memory_bank_app::SETTINGS_SCHEMA_VERSION, + active_namespace: Some("work".to_string()), + service: Some(ServiceSettings { + port: Some(4545), + autostart: Some(true), + }), + server: Some(ServerSettings { + llm_provider: Some("anthropic".to_string()), + llm_model: Some("claude-custom".to_string()), + ..ServerSettings::default() + }), + integrations: None, + }; + let mut secrets = SecretStore::default(); + secrets.set("ANTHROPIC_API_KEY", "from-secrets"); + + fs::create_dir_all(&paths.bin_dir).expect("bin dir"); + fs::write(paths.binary_path(SERVER_BINARY_NAME), "").expect("server placeholder"); + #[cfg(unix)] + make_runnable(&paths.binary_path(SERVER_BINARY_NAME)); + let spec = build_server_launch_spec(&paths, &settings, &secrets).expect("spec"); + + assert_eq!(spec.program, paths.binary_path(SERVER_BINARY_NAME)); + assert!(spec.args.contains(&"--port".to_string())); + assert_eq!( + spec.env.get("ANTHROPIC_API_KEY").map(String::as_str), + Some("from-secrets") + ); + assert!(spec.remove_env.contains(&"ANTHROPIC_API_KEY")); + assert_eq!( + spec.env.get("MEMORY_BANK_LLM_MODEL").map(String::as_str), + Some("claude-custom") + ); + } + + #[test] + fn launchd_plist_points_to_mb_binary_and_log_file() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let rendered = render_launchd_plist(&paths, true); + assert!(rendered.contains("internal")); + assert!(rendered.contains("run-server")); + assert!(rendered.contains(paths.binary_path(MB_BINARY_NAME).to_string_lossy().as_ref())); + assert!(rendered.contains(paths.log_file.to_string_lossy().as_ref())); + assert!(rendered.contains("")); + } + + #[test] + fn launchd_plist_disables_run_at_load_when_autostart_is_off() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let rendered = render_launchd_plist(&paths, false); + + assert!(rendered.contains("RunAtLoad\n ")); + assert!(rendered.contains("KeepAlive\n ")); + } + + #[test] + fn systemd_unit_redirects_to_app_log_file() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let rendered = render_systemd_unit(&paths); + assert!(rendered.contains("ExecStart=/bin/sh -lc")); + assert!(rendered.contains("internal run-server")); + assert!(rendered.contains("server.log")); + } + + #[test] + fn launch_spec_requires_remote_encoder_api_key_when_remote_api_is_selected() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings = AppSettings { + server: Some(ServerSettings { + llm_provider: Some("ollama".to_string()), + encoder_provider: Some("remote-api".to_string()), + remote_encoder_url: Some("https://encoder.example.com".to_string()), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + let secrets = SecretStore::default(); + + fs::create_dir_all(&paths.bin_dir).expect("bin dir"); + fs::write(paths.binary_path(SERVER_BINARY_NAME), "").expect("server placeholder"); + #[cfg(unix)] + make_runnable(&paths.binary_path(SERVER_BINARY_NAME)); + + let error = + build_server_launch_spec(&paths, &settings, &secrets).expect_err("missing api key"); + + assert!( + error + .to_string() + .contains("MEMORY_BANK_REMOTE_ENCODER_API_KEY") + ); + } + + #[test] + fn launch_spec_requires_provider_secret_for_hosted_models() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings = AppSettings { + server: Some(ServerSettings { + llm_provider: Some("gemini".to_string()), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + let secrets = SecretStore::default(); + + fs::create_dir_all(&paths.bin_dir).expect("bin dir"); + fs::write(paths.binary_path(SERVER_BINARY_NAME), "").expect("server placeholder"); + #[cfg(unix)] + make_runnable(&paths.binary_path(SERVER_BINARY_NAME)); + + let error = + build_server_launch_spec(&paths, &settings, &secrets).expect_err("missing secret"); + + assert!(matches!( + error, + AppError::MissingProviderSecret("GEMINI_API_KEY") + )); + } + + #[test] + fn launch_spec_rejects_blank_provider_secret_values() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings = AppSettings { + server: Some(ServerSettings { + llm_provider: Some("gemini".to_string()), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + let mut secrets = SecretStore::default(); + secrets.set("GEMINI_API_KEY", " "); + + fs::create_dir_all(&paths.bin_dir).expect("bin dir"); + fs::write(paths.binary_path(SERVER_BINARY_NAME), "").expect("server placeholder"); + #[cfg(unix)] + make_runnable(&paths.binary_path(SERVER_BINARY_NAME)); + + let error = + build_server_launch_spec(&paths, &settings, &secrets).expect_err("blank secret"); + + assert!(matches!( + error, + AppError::MissingProviderSecret("GEMINI_API_KEY") + )); + } + + #[test] + fn launch_spec_requires_local_encoder_url_for_local_api() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings = AppSettings { + server: Some(ServerSettings { + llm_provider: Some("ollama".to_string()), + encoder_provider: Some("local-api".to_string()), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + let secrets = SecretStore::default(); + + fs::create_dir_all(&paths.bin_dir).expect("bin dir"); + fs::write(paths.binary_path(SERVER_BINARY_NAME), "").expect("server placeholder"); + #[cfg(unix)] + make_runnable(&paths.binary_path(SERVER_BINARY_NAME)); + + let error = build_server_launch_spec(&paths, &settings, &secrets).expect_err("missing url"); + + assert!(error.to_string().contains("server.local_encoder_url")); + } + + #[test] + fn launch_spec_includes_ollama_and_remote_encoder_environment() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings = AppSettings { + service: Some(ServiceSettings { + port: Some(4040), + autostart: None, + }), + server: Some(ServerSettings { + llm_provider: Some("ollama".to_string()), + llm_model: Some("llama3.1".to_string()), + ollama_url: Some("http://ollama.internal:11434/".to_string()), + encoder_provider: Some("remote-api".to_string()), + remote_encoder_url: Some("https://encoder.example.com".to_string()), + nearest_neighbor_count: Some(15), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + let mut secrets = SecretStore::default(); + secrets.set("MEMORY_BANK_REMOTE_ENCODER_API_KEY", "remote-secret"); + + fs::create_dir_all(&paths.bin_dir).expect("bin dir"); + fs::write(paths.binary_path(SERVER_BINARY_NAME), "").expect("server placeholder"); + #[cfg(unix)] + make_runnable(&paths.binary_path(SERVER_BINARY_NAME)); + + let spec = build_server_launch_spec(&paths, &settings, &secrets).expect("spec"); + + assert_eq!( + spec.env.get("MEMORY_BANK_OLLAMA_MODEL").map(String::as_str), + Some("llama3.1") + ); + assert_eq!( + spec.env.get("MEMORY_BANK_OLLAMA_URL").map(String::as_str), + Some("http://ollama.internal:11434") + ); + assert_eq!( + spec.env + .get("MEMORY_BANK_REMOTE_ENCODER_API_KEY") + .map(String::as_str), + Some("remote-secret") + ); + assert_eq!( + spec.env + .get("MEMORY_BANK_REMOTE_ENCODER_URL") + .map(String::as_str), + Some("https://encoder.example.com") + ); + assert!(spec.args.contains(&"4040".to_string())); + assert!(spec.args.contains(&"15".to_string())); + } + + #[test] + fn launch_spec_rejects_invalid_nearest_neighbor_count() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings = AppSettings { + server: Some(ServerSettings { + llm_provider: Some("ollama".to_string()), + nearest_neighbor_count: Some(0), + ..ServerSettings::default() + }), + ..AppSettings::default() + }; + let secrets = SecretStore::default(); + + fs::create_dir_all(&paths.bin_dir).expect("bin dir"); + fs::write(paths.binary_path(SERVER_BINARY_NAME), "").expect("server placeholder"); + #[cfg(unix)] + make_runnable(&paths.binary_path(SERVER_BINARY_NAME)); + + let error = build_server_launch_spec(&paths, &settings, &secrets) + .expect_err("invalid nearest neighbor count"); + + assert!(error.to_string().contains("at least 1")); + } + + #[test] + fn launchctl_pid_parser_extracts_pid_when_present() { + let output = "service = com.memory-bank.mb\n pid = 4242\n"; + + assert_eq!(launchctl_pid_from_output(output), Some(4242)); + assert_eq!( + launchctl_pid_from_output("service = com.memory-bank.mb"), + None + ); + } + + #[test] + fn systemd_pid_parser_ignores_empty_and_zero_values() { + assert_eq!(systemd_main_pid_from_output("4242"), Some(4242)); + assert_eq!(systemd_main_pid_from_output("0"), None); + assert_eq!(systemd_main_pid_from_output(""), None); + } + + #[test] + fn load_server_startup_state_uses_matching_pid_and_namespace() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings = AppSettings::default(); + let namespace = settings.active_namespace(); + fs::create_dir_all(paths.namespace_dir(&namespace)).expect("namespace dir"); + let startup_state = memory_bank_app::ServerStartupState { + pid: 4242, + namespace: namespace.to_string(), + phase: memory_bank_app::ServerStartupPhase::Reindexing, + memory_count: Some(12), + }; + fs::write( + paths.server_startup_state_path(&namespace), + serde_json::to_vec_pretty(&startup_state).expect("startup state json"), + ) + .expect("startup state file"); + + let loaded = load_server_startup_state(&paths, &settings, true, Some(4242)); + + assert_eq!(loaded, Some(startup_state)); + } + + #[test] + fn load_server_startup_state_ignores_pid_mismatch() { + let temp = TempDir::new().expect("tempdir"); + let paths = AppPaths::from_home_dir(temp.path().to_path_buf()); + let settings = AppSettings::default(); + let namespace = settings.active_namespace(); + fs::create_dir_all(paths.namespace_dir(&namespace)).expect("namespace dir"); + let startup_state = memory_bank_app::ServerStartupState { + pid: 1111, + namespace: namespace.to_string(), + phase: memory_bank_app::ServerStartupPhase::Reindexing, + memory_count: Some(12), + }; + fs::write( + paths.server_startup_state_path(&namespace), + serde_json::to_vec_pretty(&startup_state).expect("startup state json"), + ) + .expect("startup state file"); + + let loaded = load_server_startup_state(&paths, &settings, true, Some(4242)); + + assert_eq!(loaded, None); + } +} diff --git a/memory-bank-cli/src/service/definitions.rs b/memory-bank-cli/src/service/definitions.rs new file mode 100644 index 0000000..f18721b --- /dev/null +++ b/memory-bank-cli/src/service/definitions.rs @@ -0,0 +1,94 @@ +use crate::AppError; +use crate::command_utils::{run_command, shell_escape}; +use crate::constants::{LAUNCHD_LABEL, MB_BINARY_NAME, SYSTEMD_UNIT_NAME}; +use memory_bank_app::{AppPaths, AppSettings}; +use std::fs; +use std::path::PathBuf; + +pub(super) fn install_launchd_service( + paths: &AppPaths, + settings: &AppSettings, +) -> Result<(), AppError> { + let service_path = launchd_service_path(paths); + if let Some(parent) = service_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write( + &service_path, + render_launchd_plist(paths, settings.resolved_autostart()), + )?; + Ok(()) +} + +pub(super) fn install_systemd_service( + paths: &AppPaths, + settings: &AppSettings, +) -> Result<(), AppError> { + let service_path = systemd_service_path(paths); + if let Some(parent) = service_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&service_path, render_systemd_unit(paths))?; + run_command("systemctl", &["--user", "daemon-reload"])?; + if settings.resolved_autostart() { + run_command("systemctl", &["--user", "enable", SYSTEMD_UNIT_NAME])?; + } else { + let _ = run_command("systemctl", &["--user", "disable", SYSTEMD_UNIT_NAME]); + } + Ok(()) +} + +pub(super) fn render_launchd_plist(paths: &AppPaths, autostart: bool) -> String { + let launch_flag = if autostart { "" } else { "" }; + format!( + r#" + + + + Label + {label} + ProgramArguments + + {program} + internal + run-server + + RunAtLoad + {launch_flag} + KeepAlive + {launch_flag} + StandardOutPath + {log_file} + StandardErrorPath + {log_file} + + +"#, + label = LAUNCHD_LABEL, + program = paths.binary_path(MB_BINARY_NAME).display(), + log_file = paths.log_file.display(), + launch_flag = launch_flag, + ) +} + +pub(super) fn render_systemd_unit(paths: &AppPaths) -> String { + let escaped_mb = shell_escape(paths.binary_path(MB_BINARY_NAME).to_string_lossy().as_ref()); + let escaped_log = shell_escape(paths.log_file.to_string_lossy().as_ref()); + format!( + "[Unit]\nDescription=Memory Bank\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/bin/sh -lc 'exec {escaped_mb} internal run-server >> {escaped_log} 2>&1'\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n" + ) +} + +pub(super) fn launchd_service_path(paths: &AppPaths) -> PathBuf { + paths + .home_dir + .join("Library/LaunchAgents") + .join(format!("{LAUNCHD_LABEL}.plist")) +} + +pub(super) fn systemd_service_path(paths: &AppPaths) -> PathBuf { + paths + .home_dir + .join(".config/systemd/user") + .join(SYSTEMD_UNIT_NAME) +} diff --git a/memory-bank-cli/src/service/launch.rs b/memory-bank-cli/src/service/launch.rs new file mode 100644 index 0000000..d34b83e --- /dev/null +++ b/memory-bank-cli/src/service/launch.rs @@ -0,0 +1,277 @@ +use crate::AppError; +use crate::assets::{ExposureCheck, inspect_cli_exposure}; +use crate::config::{ + llm_provider_value, normalize_ollama_url, validate_encoder_provider, validate_llm_provider, +}; +use crate::constants::{ + HOOK_BINARY_NAME, MB_BINARY_NAME, MCP_PROXY_BINARY_NAME, SERVER_BINARY_NAME, +}; +use crate::domain::{EncoderProviderId, ProviderId}; +use memory_bank_app::{AppPaths, AppSettings, SecretStore}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use super::ServerLaunchSpec; + +pub(crate) fn build_server_launch_spec( + paths: &AppPaths, + settings: &AppSettings, + secrets: &SecretStore, +) -> Result { + let port = settings.resolved_port(); + if port == 0 { + return Err(AppError::InvalidConfigValue( + "service.port".to_string(), + "must be between 1 and 65535".to_string(), + )); + } + + let program = if is_runnable_file(&paths.binary_path(SERVER_BINARY_NAME)) { + paths.binary_path(SERVER_BINARY_NAME) + } else { + let current_exe = std::env::current_exe()?; + let sibling = current_exe + .parent() + .ok_or_else(|| AppError::MissingBinary(SERVER_BINARY_NAME.to_string()))? + .join(SERVER_BINARY_NAME); + if is_runnable_file(&sibling) { + sibling + } else { + return Err(AppError::MissingBinary(SERVER_BINARY_NAME.to_string())); + } + }; + + let server_settings = settings.server.clone().unwrap_or_default(); + let provider = validate_llm_provider(llm_provider_value(settings), "server.llm_provider")?; + let encoder_provider = validate_encoder_provider( + server_settings + .encoder_provider + .as_deref() + .unwrap_or("fast-embed"), + "server.encoder_provider", + )?; + match encoder_provider { + EncoderProviderId::LocalApi => { + if normalized_non_empty(server_settings.local_encoder_url.as_deref()).is_none() { + return Err(AppError::InvalidConfigValue( + "server.local_encoder_url".to_string(), + "must be set when encoder provider is local-api".to_string(), + )); + } + } + EncoderProviderId::RemoteApi => { + if normalized_non_empty(server_settings.remote_encoder_url.as_deref()).is_none() { + return Err(AppError::InvalidConfigValue( + "server.remote_encoder_url".to_string(), + "must be set when encoder provider is remote-api".to_string(), + )); + } + } + EncoderProviderId::FastEmbed => {} + } + let nearest_neighbor_count = server_settings.nearest_neighbor_count.unwrap_or(10); + if nearest_neighbor_count < 1 { + return Err(AppError::InvalidConfigValue( + "server.nearest_neighbor_count".to_string(), + "must be at least 1".to_string(), + )); + } + + let mut env = std::collections::BTreeMap::new(); + if let Some(secret_key) = provider.secret_env_key() { + let secret = require_non_empty_secret(secrets, secret_key) + .ok_or(AppError::MissingProviderSecret(secret_key))?; + env.insert(secret_key.to_string(), secret.to_string()); + } + if encoder_provider == EncoderProviderId::RemoteApi { + let secret = require_non_empty_secret(secrets, "MEMORY_BANK_REMOTE_ENCODER_API_KEY") + .ok_or(AppError::Message( + "missing required remote encoder secret `MEMORY_BANK_REMOTE_ENCODER_API_KEY` in ~/.memory_bank/secrets.env" + .to_string(), + ))?; + env.insert( + "MEMORY_BANK_REMOTE_ENCODER_API_KEY".to_string(), + secret.to_string(), + ); + } + + match provider { + ProviderId::Ollama => { + if let Some(model) = server_settings.llm_model.clone() { + env.insert("MEMORY_BANK_OLLAMA_MODEL".to_string(), model); + } + if let Some(url) = server_settings.ollama_url.clone() { + env.insert( + "MEMORY_BANK_OLLAMA_URL".to_string(), + normalize_ollama_url(&url), + ); + } + } + _ => { + if let Some(model) = server_settings.llm_model.clone() { + env.insert("MEMORY_BANK_LLM_MODEL".to_string(), model); + } + } + } + if let Some(model) = server_settings.fastembed_model.clone() { + env.insert("MEMORY_BANK_FASTEMBED_MODEL".to_string(), model); + } + if let Some(url) = server_settings.local_encoder_url.clone() { + env.insert("MEMORY_BANK_LOCAL_ENCODER_URL".to_string(), url); + } + if let Some(url) = server_settings.remote_encoder_url.clone() { + env.insert("MEMORY_BANK_REMOTE_ENCODER_URL".to_string(), url); + } + + Ok(ServerLaunchSpec { + program, + args: vec![ + "--port".to_string(), + port.to_string(), + "--namespace".to_string(), + settings.active_namespace().to_string(), + "--llm-provider".to_string(), + provider.as_str().to_string(), + "--encoder-provider".to_string(), + encoder_provider.as_str().to_string(), + "--history-window-size".to_string(), + server_settings.history_window_size.unwrap_or(0).to_string(), + "--nearest-neighbor-count".to_string(), + nearest_neighbor_count.to_string(), + ], + env, + remove_env: vec![ + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "OPENAI_API_KEY", + "MEMORY_BANK_LLM_MODEL", + "MEMORY_BANK_FASTEMBED_MODEL", + "MEMORY_BANK_LOCAL_ENCODER_URL", + "MEMORY_BANK_REMOTE_ENCODER_URL", + "MEMORY_BANK_OLLAMA_MODEL", + "MEMORY_BANK_OLLAMA_URL", + "MEMORY_BANK_REMOTE_ENCODER_API_KEY", + ], + }) +} + +pub(crate) fn collect_doctor_issues( + paths: &AppPaths, + settings: &AppSettings, +) -> Result, AppError> { + let mut issues = Vec::new(); + let secrets = SecretStore::load(paths)?; + + if !paths.settings_file.exists() { + issues.push(format!("{} is missing", paths.settings_file.display())); + } + if !is_runnable_file(&paths.binary_path(MB_BINARY_NAME)) { + issues.push("mb is not installed under ~/.memory_bank/bin".to_string()); + } + for binary in [SERVER_BINARY_NAME, HOOK_BINARY_NAME, MCP_PROXY_BINARY_NAME] { + if !is_runnable_file(&paths.binary_path(binary)) { + issues.push(format!("{binary} is missing from ~/.memory_bank/bin")); + } + } + match inspect_cli_exposure(paths)? { + ExposureCheck::Active(_) => {} + ExposureCheck::Missing => { + issues.push( + "no managed `mb` exposure was found for the current shell or future shells" + .to_string(), + ); + } + ExposureCheck::Collision(path) => { + issues.push(format!( + "another `mb` executable already exists on PATH at {}", + path.display() + )); + } + } + + if let Some(env_key) = + ProviderId::from_config_value(Some(llm_provider_value(settings))).secret_env_key() + && require_non_empty_secret(&secrets, env_key).is_none() + { + issues.push(format!("missing {env_key} in ~/.memory_bank/secrets.env")); + } + + match settings + .server + .as_ref() + .and_then(|server| server.encoder_provider.as_deref()) + .unwrap_or("fast-embed") + { + "local-api" => { + if normalized_non_empty( + settings + .server + .as_ref() + .and_then(|server| server.local_encoder_url.as_deref()), + ) + .is_none() + { + issues.push("server.local_encoder_url must be set for local-api".to_string()); + } + } + "remote-api" => { + if normalized_non_empty( + settings + .server + .as_ref() + .and_then(|server| server.remote_encoder_url.as_deref()), + ) + .is_none() + { + issues.push("server.remote_encoder_url must be set for remote-api".to_string()); + } + if require_non_empty_secret(&secrets, "MEMORY_BANK_REMOTE_ENCODER_API_KEY").is_none() { + issues.push( + "missing MEMORY_BANK_REMOTE_ENCODER_API_KEY in ~/.memory_bank/secrets.env" + .to_string(), + ); + } + } + _ => {} + } + + let service = super::service_status(paths)?; + if !service.installed { + issues.push("managed service is not installed".to_string()); + } else if !service.active { + issues.push("managed service is not active".to_string()); + } + + if super::fetch_health(settings).is_err() { + issues.push("health check to /healthz failed".to_string()); + } + + Ok(issues) +} + +fn normalized_non_empty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn require_non_empty_secret<'a>(secrets: &'a SecretStore, key: &str) -> Option<&'a str> { + secrets.get(key).filter(|value| !value.trim().is_empty()) +} + +pub(super) fn is_runnable_file(path: &Path) -> bool { + if !path.is_file() { + return false; + } + + #[cfg(unix)] + { + path.metadata() + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + + #[cfg(not(unix))] + { + true + } +} diff --git a/memory-bank-cli/src/setup.rs b/memory-bank-cli/src/setup.rs new file mode 100644 index 0000000..63a41e3 --- /dev/null +++ b/memory-bank-cli/src/setup.rs @@ -0,0 +1,168 @@ +mod plan; +mod prompts; +mod render; + +use crate::AppError; +use crate::agents::{AgentSetupOutcome, configure_selected_agents, detect_installed_agents}; +use crate::assets::{ExposureOutcome, materialize_and_expose_cli}; +use crate::config::fastembed_reindex_change; +use crate::constants::{HEALTH_POLL_INTERVAL, HEALTH_STARTUP_TIMEOUT}; +use crate::output::{styled_failure, styled_subtle, styled_success, styled_warning}; +use crate::service::{HealthCheck, install_service, start_service, wait_for_health}; +use memory_bank_app::{AppPaths, AppSettings, SecretStore, default_server_url}; +use std::io::{self, Write}; + +use self::plan::{SetupPlan, apply_secret_choice, build_settings_for_plan}; +use self::prompts::{collect_setup_plan, configure_setup_rendering, ensure_interactive_terminal}; +use self::render::{render_post_setup_help, render_review_summary}; + +pub(crate) fn run_setup() -> Result<(), AppError> { + ensure_interactive_terminal()?; + configure_setup_rendering(); + + let paths = AppPaths::from_system()?; + let model_catalog = crate::models::refresh_model_catalog(&paths); + let mut settings = AppSettings::load(&paths)?; + let mut secrets = SecretStore::load(&paths)?; + let detected_agents = detect_installed_agents(); + let plan = match collect_setup_plan(&settings, &secrets, &detected_agents, &model_catalog) { + Ok(plan) => plan, + Err(AppError::SetupCanceled) => { + println!( + "{}", + styled_warning("Setup canceled. No changes were made.") + ); + return Ok(()); + } + Err(error) => return Err(error), + }; + + println!(); + println!("{}", render_review_summary(&plan)); + let preview_settings = build_settings_for_plan(&settings, &plan, &[]); + if let Some(change) = fastembed_reindex_change(&settings, &preview_settings) { + println!(); + println!( + "{}", + styled_warning(&format!( + "Warning: Changing the FastEmbed model from `{}` to `{}` means the next service start will rebuild the vector index and re-encode existing memories for this namespace.", + change.previous_model, change.new_model + )) + ); + } + println!(); + + let confirm = inquire::Confirm::new("Apply these changes now?") + .with_default(true) + .with_help_message( + "Nothing under ~/.memory_bank or your agent config files will change until you confirm.", + ) + .prompt_skippable()?; + if !matches!(confirm, Some(true)) { + println!( + "{}", + styled_warning("Setup canceled. No changes were made.") + ); + return Ok(()); + } + + let (health, agent_outcome, exposure) = + apply_setup_plan(&paths, &mut settings, &mut secrets, &plan)?; + println!(); + println!( + "{}", + styled_success(&format!( + "Memory Bank is ready on {} using namespace `{}` and provider `{}`.", + default_server_url(&settings), + health.namespace, + health.llm_provider + )) + ); + if !agent_outcome.warnings.is_empty() { + println!( + "{}", + styled_warning("Some agent integrations need attention:") + ); + for warning in agent_outcome.warnings { + println!(" - {warning}"); + } + } + println!(); + println!("{}", render_post_setup_help(&exposure)); + Ok(()) +} + +fn apply_setup_plan( + paths: &AppPaths, + settings: &mut AppSettings, + secrets: &mut SecretStore, + plan: &SetupPlan, +) -> Result<(HealthCheck, AgentSetupOutcome, ExposureOutcome), AppError> { + let total_steps = 6; + let preview_settings = build_settings_for_plan(settings, plan, &[]); + + let exposure = apply_step(1, total_steps, "Install artifacts and expose CLI", || { + paths.ensure_base_dirs()?; + materialize_and_expose_cli(paths) + })?; + + let agent_outcome = { + print_step_start(2, total_steps, "Configure selected agents")?; + let outcome = configure_selected_agents(paths, &preview_settings, &plan.selected_agents)?; + if plan.selected_agents.is_empty() { + println!("{}", styled_subtle("skipped (no agents selected)")); + } else if outcome.warnings.is_empty() { + println!("{}", styled_success("done")); + } else { + println!("{}", styled_warning("done with warnings")); + } + outcome + }; + + *settings = build_settings_for_plan(settings, plan, &agent_outcome.configured); + + apply_step(3, total_steps, "Write settings and secrets", || { + apply_secret_choice(secrets, &plan.secret_choice); + settings.save(paths)?; + secrets.save(paths)?; + Ok(()) + })?; + + apply_step(4, total_steps, "Install managed service", || { + install_service(paths, settings) + })?; + apply_step(5, total_steps, "Start managed service", || { + start_service(paths, settings) + })?; + let health = apply_step(6, total_steps, "Wait for service health", || { + wait_for_health(settings, HEALTH_STARTUP_TIMEOUT, HEALTH_POLL_INTERVAL) + })?; + + Ok((health, agent_outcome, exposure)) +} + +fn print_step_start(index: usize, total: usize, label: &str) -> Result<(), AppError> { + print!( + "{} {label}... ", + styled_subtle(&format!("[{index}/{total}]")) + ); + io::stdout().flush()?; + Ok(()) +} + +fn apply_step(index: usize, total: usize, label: &str, action: F) -> Result +where + F: FnOnce() -> Result, +{ + print_step_start(index, total, label)?; + match action() { + Ok(value) => { + println!("{}", styled_success("done")); + Ok(value) + } + Err(error) => { + println!("{}", styled_failure("failed")); + Err(error) + } + } +} diff --git a/memory-bank-cli/src/setup/plan.rs b/memory-bank-cli/src/setup/plan.rs new file mode 100644 index 0000000..d0e2bb4 --- /dev/null +++ b/memory-bank-cli/src/setup/plan.rs @@ -0,0 +1,379 @@ +use crate::agents::AgentKind; +use crate::config::{set_integrations, set_server, set_service}; +use crate::constants::{DEFAULT_HISTORY_WINDOW_SIZE, DEFAULT_NEAREST_NEIGHBOR_COUNT}; +use crate::domain::{ProviderId, integration_configured, set_integration_configured}; +use memory_bank_app::{ + AppSettings, DEFAULT_FASTEMBED_MODEL, DEFAULT_NAMESPACE_NAME, DEFAULT_OLLAMA_URL, DEFAULT_PORT, + Namespace, SETTINGS_SCHEMA_VERSION, SecretStore, +}; + +#[derive(Debug, Clone)] +pub(super) struct SetupPlan { + pub(super) namespace: Namespace, + pub(super) provider: ProviderId, + pub(super) model: String, + pub(super) ollama_url: Option, + pub(super) autostart: bool, + pub(super) selected_agents: Vec, + pub(super) secret_choice: SecretChoice, + pub(super) advanced: AdvancedSettings, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct AdvancedSettings { + pub(super) port: u16, + pub(super) fastembed_model: String, + pub(super) history_window_size: u32, + pub(super) nearest_neighbor_count: i32, +} + +#[derive(Debug, Clone)] +pub(super) enum SecretChoice { + NotRequired, + KeepStored { key: &'static str }, + UseEnvironment { key: &'static str, value: String }, + ManualEntry { key: &'static str, value: String }, +} + +impl AdvancedSettings { + pub(super) fn from_settings(settings: &AppSettings) -> Self { + let server = settings.server.as_ref(); + Self { + port: settings.resolved_port(), + fastembed_model: server + .and_then(|server| server.fastembed_model.clone()) + .unwrap_or_else(|| DEFAULT_FASTEMBED_MODEL.to_string()), + history_window_size: server + .and_then(|server| server.history_window_size) + .unwrap_or(DEFAULT_HISTORY_WINDOW_SIZE), + nearest_neighbor_count: server + .and_then(|server| server.nearest_neighbor_count) + .unwrap_or(DEFAULT_NEAREST_NEIGHBOR_COUNT), + } + } + + pub(super) fn has_overrides(&self) -> bool { + self.port != DEFAULT_PORT + || self.fastembed_model != DEFAULT_FASTEMBED_MODEL + || self.history_window_size != DEFAULT_HISTORY_WINDOW_SIZE + || self.nearest_neighbor_count != DEFAULT_NEAREST_NEIGHBOR_COUNT + } + + pub(super) fn override_lines(&self) -> Vec { + let mut lines = Vec::new(); + if self.port != DEFAULT_PORT { + lines.push(format!("Port: {}", self.port)); + } + if self.fastembed_model != DEFAULT_FASTEMBED_MODEL { + lines.push(format!("FastEmbed model: {}", self.fastembed_model)); + } + if self.history_window_size != DEFAULT_HISTORY_WINDOW_SIZE { + lines.push(format!("History window size: {}", self.history_window_size)); + } + if self.nearest_neighbor_count != DEFAULT_NEAREST_NEIGHBOR_COUNT { + lines.push(format!( + "Nearest neighbor count: {}", + self.nearest_neighbor_count + )); + } + lines + } +} + +impl SecretChoice { + pub(super) fn summary(&self) -> String { + match self { + Self::NotRequired => "No provider secret required for Ollama".to_string(), + Self::KeepStored { key } => { + format!("Use the existing {key} from ~/.memory_bank/secrets.env") + } + Self::UseEnvironment { key, .. } => { + format!("Use the current shell {key} and store it in ~/.memory_bank/secrets.env") + } + Self::ManualEntry { key, .. } => { + format!("Store a newly entered {key} in ~/.memory_bank/secrets.env") + } + } + } +} + +pub(super) fn build_settings_for_plan( + current: &AppSettings, + plan: &SetupPlan, + configured_agents: &[AgentKind], +) -> AppSettings { + let mut settings = current.clone(); + settings.schema_version = SETTINGS_SCHEMA_VERSION; + settings.active_namespace = if plan.namespace.as_ref() == DEFAULT_NAMESPACE_NAME { + None + } else { + Some(plan.namespace.to_string()) + }; + + let mut service = settings.service.clone().unwrap_or_default(); + service.autostart = plan.autostart.then_some(true); + service.port = (plan.advanced.port != DEFAULT_PORT).then_some(plan.advanced.port); + set_service(&mut settings, service); + + let mut server = settings.server.clone().unwrap_or_default(); + server.llm_provider = if plan.provider == ProviderId::Anthropic { + None + } else { + Some(plan.provider.as_str().to_string()) + }; + server.llm_model = if plan.model == plan.provider.default_model() { + None + } else { + Some(plan.model.clone()) + }; + server.ollama_url = if plan.provider == ProviderId::Ollama { + match plan.ollama_url.as_deref() { + Some(url) if url != DEFAULT_OLLAMA_URL => Some(url.to_string()), + _ => None, + } + } else { + None + }; + server.fastembed_model = if plan.advanced.fastembed_model == DEFAULT_FASTEMBED_MODEL { + None + } else { + Some(plan.advanced.fastembed_model.clone()) + }; + server.history_window_size = (plan.advanced.history_window_size != DEFAULT_HISTORY_WINDOW_SIZE) + .then_some(plan.advanced.history_window_size); + server.nearest_neighbor_count = (plan.advanced.nearest_neighbor_count + != DEFAULT_NEAREST_NEIGHBOR_COUNT) + .then_some(plan.advanced.nearest_neighbor_count); + set_server(&mut settings, server); + + let mut integrations = current.integrations.clone().unwrap_or_default(); + for agent in AgentKind::all() { + let configured = configured_agents.contains(&agent) + || integration_configured(current.integrations.as_ref(), agent); + set_integration_configured(&mut integrations, agent, configured); + } + set_integrations(&mut settings, integrations); + + settings +} + +pub(super) fn apply_secret_choice(secrets: &mut SecretStore, choice: &SecretChoice) { + match choice { + SecretChoice::NotRequired | SecretChoice::KeepStored { .. } => {} + SecretChoice::UseEnvironment { key, value } | SecretChoice::ManualEntry { key, value } => { + secrets.set(*key, value.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn advanced_settings_default_from_settings_have_no_overrides() { + let settings = AppSettings::default(); + let advanced = AdvancedSettings::from_settings(&settings); + + assert_eq!(advanced.port, DEFAULT_PORT); + assert_eq!(advanced.fastembed_model, DEFAULT_FASTEMBED_MODEL); + assert_eq!(advanced.history_window_size, DEFAULT_HISTORY_WINDOW_SIZE); + assert_eq!( + advanced.nearest_neighbor_count, + DEFAULT_NEAREST_NEIGHBOR_COUNT + ); + assert!(!advanced.has_overrides()); + } + + #[test] + fn build_settings_for_plan_applies_advanced_overrides() { + let current = AppSettings::default(); + let plan = SetupPlan { + namespace: Namespace::new("work"), + provider: ProviderId::Gemini, + model: "gemini-3.1-pro-preview".to_string(), + ollama_url: None, + autostart: true, + selected_agents: vec![AgentKind::OpenCode], + secret_choice: SecretChoice::NotRequired, + advanced: AdvancedSettings { + port: 4545, + fastembed_model: "custom/embed-model".to_string(), + history_window_size: 25, + nearest_neighbor_count: 15, + }, + }; + + let settings = build_settings_for_plan(¤t, &plan, &[AgentKind::OpenCode]); + let service = settings.service.expect("service settings"); + let server = settings.server.expect("server settings"); + let integrations = settings.integrations.expect("integrations"); + + assert_eq!(settings.active_namespace.as_deref(), Some("work")); + assert_eq!(service.port, Some(4545)); + assert_eq!(service.autostart, Some(true)); + assert_eq!(server.llm_provider.as_deref(), Some("gemini")); + assert_eq!(server.llm_model.as_deref(), Some("gemini-3.1-pro-preview")); + assert_eq!( + server.fastembed_model.as_deref(), + Some("custom/embed-model") + ); + assert_eq!(server.history_window_size, Some(25)); + assert_eq!(server.nearest_neighbor_count, Some(15)); + assert_eq!(server.ollama_url, None); + assert_eq!( + integrations.opencode.as_ref().map(|state| state.configured), + Some(true) + ); + assert_eq!( + integrations + .claude_code + .as_ref() + .map(|state| state.configured), + Some(false) + ); + } + + #[test] + fn build_settings_for_ollama_plan_persists_non_default_url() { + let plan = SetupPlan { + namespace: Namespace::new("default"), + provider: ProviderId::Ollama, + model: "qwen3".to_string(), + ollama_url: Some("http://192.168.1.50:11434".to_string()), + autostart: false, + selected_agents: Vec::new(), + secret_choice: SecretChoice::NotRequired, + advanced: AdvancedSettings::from_settings(&AppSettings::default()), + }; + + let settings = build_settings_for_plan(&AppSettings::default(), &plan, &[]); + let server = settings.server.expect("server settings"); + + assert_eq!(server.llm_provider.as_deref(), Some("ollama")); + assert_eq!( + server.ollama_url.as_deref(), + Some("http://192.168.1.50:11434") + ); + } + + #[test] + fn build_settings_for_plan_preserves_unselected_integrations() { + let current = AppSettings { + integrations: Some(memory_bank_app::IntegrationsSettings { + claude_code: Some(memory_bank_app::IntegrationState { configured: true }), + gemini_cli: Some(memory_bank_app::IntegrationState { configured: false }), + opencode: Some(memory_bank_app::IntegrationState { configured: true }), + openclaw: Some(memory_bank_app::IntegrationState { configured: true }), + }), + ..AppSettings::default() + }; + let plan = SetupPlan { + namespace: Namespace::new("default"), + provider: ProviderId::Anthropic, + model: memory_bank_app::DEFAULT_ANTHROPIC_MODEL.to_string(), + ollama_url: None, + autostart: false, + selected_agents: vec![AgentKind::GeminiCli], + secret_choice: SecretChoice::NotRequired, + advanced: AdvancedSettings::from_settings(&AppSettings::default()), + }; + + let settings = build_settings_for_plan(¤t, &plan, &[AgentKind::GeminiCli]); + let integrations = settings.integrations.expect("integrations"); + + assert_eq!( + integrations + .claude_code + .as_ref() + .map(|state| state.configured), + Some(true) + ); + assert_eq!( + integrations + .gemini_cli + .as_ref() + .map(|state| state.configured), + Some(true) + ); + assert_eq!( + integrations.opencode.as_ref().map(|state| state.configured), + Some(true) + ); + assert_eq!( + integrations.openclaw.as_ref().map(|state| state.configured), + Some(true) + ); + } + + #[test] + fn build_settings_for_default_plan_clears_default_provider_and_model() { + let plan = SetupPlan { + namespace: Namespace::new("default"), + provider: ProviderId::Anthropic, + model: memory_bank_app::DEFAULT_ANTHROPIC_MODEL.to_string(), + ollama_url: None, + autostart: false, + selected_agents: Vec::new(), + secret_choice: SecretChoice::NotRequired, + advanced: AdvancedSettings::from_settings(&AppSettings::default()), + }; + + let settings = build_settings_for_plan(&AppSettings::default(), &plan, &[]); + + assert_eq!(settings.active_namespace, None); + assert!(settings.server.is_none()); + assert!(settings.service.is_none()); + } + + #[test] + fn build_settings_switching_from_ollama_clears_saved_ollama_url() { + let current = AppSettings { + server: Some(memory_bank_app::ServerSettings { + llm_provider: Some("ollama".to_string()), + ollama_url: Some("http://ollama.internal:11434".to_string()), + ..memory_bank_app::ServerSettings::default() + }), + ..AppSettings::default() + }; + let plan = SetupPlan { + namespace: Namespace::new("default"), + provider: ProviderId::Gemini, + model: memory_bank_app::DEFAULT_GEMINI_MODEL.to_string(), + ollama_url: None, + autostart: false, + selected_agents: Vec::new(), + secret_choice: SecretChoice::NotRequired, + advanced: AdvancedSettings::from_settings(&AppSettings::default()), + }; + + let settings = build_settings_for_plan(¤t, &plan, &[]); + let server = settings.server.expect("server settings"); + + assert_eq!(server.llm_provider.as_deref(), Some("gemini")); + assert_eq!(server.ollama_url, None); + } + + #[test] + fn apply_secret_choice_only_mutates_store_when_needed() { + let mut secrets = SecretStore::default(); + secrets.set("ANTHROPIC_API_KEY", "stored"); + + apply_secret_choice( + &mut secrets, + &SecretChoice::KeepStored { + key: "ANTHROPIC_API_KEY", + }, + ); + assert_eq!(secrets.get("ANTHROPIC_API_KEY"), Some("stored")); + + apply_secret_choice( + &mut secrets, + &SecretChoice::UseEnvironment { + key: "ANTHROPIC_API_KEY", + value: "updated".to_string(), + }, + ); + assert_eq!(secrets.get("ANTHROPIC_API_KEY"), Some("updated")); + } +} diff --git a/memory-bank-cli/src/setup/prompts.rs b/memory-bank-cli/src/setup/prompts.rs new file mode 100644 index 0000000..967ccdd --- /dev/null +++ b/memory-bank-cli/src/setup/prompts.rs @@ -0,0 +1,649 @@ +use crate::AppError; +use crate::agents::AgentKind; +use crate::config::normalize_ollama_url; +use crate::domain::ProviderId; +use crate::models::{ + ModelCatalog, ModelChoice, fetch_ollama_models_for_setup, model_choices_for_provider, + model_choices_from_values, +}; +use crate::output::{no_color_requested, styled_subtle, styled_warning}; +use inquire::ui::{Attributes, Color, RenderConfig, StyleSheet, Styled}; +use inquire::validator::Validation; +use inquire::{Confirm, CustomType, MultiSelect, Select, Text, set_global_render_config}; +use memory_bank_app::{AppSettings, DEFAULT_OLLAMA_URL, Namespace, SecretStore}; +use std::io::{self, IsTerminal}; + +use super::plan::{AdvancedSettings, SecretChoice, SetupPlan}; +use super::render::{print_setup_intro, print_setup_section}; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SecretPromptPlan { + NotRequired, + OfferEnvironment { key: &'static str, value: String }, + OfferStored { key: &'static str }, + ManualEntry { key: &'static str }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum WizardStep { + Continue(T), + Canceled, +} + +impl WizardStep { + fn from_option(value: Option) -> Self { + match value { + Some(value) => Self::Continue(value), + None => Self::Canceled, + } + } + + fn into_result(self) -> Result { + match self { + Self::Continue(value) => Ok(value), + Self::Canceled => Err(AppError::SetupCanceled), + } + } +} + +pub(super) fn ensure_interactive_terminal() -> Result<(), AppError> { + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return Err(AppError::Message( + "mb setup requires an interactive terminal. Run it in a TTY or use `mb config` for manual changes.".to_string(), + )); + } + Ok(()) +} + +pub(super) fn configure_setup_rendering() { + set_global_render_config(setup_render_config()); +} + +fn setup_render_config() -> RenderConfig<'static> { + let mut config = if no_color_requested() { + RenderConfig::empty() + } else { + RenderConfig::default_colored() + }; + config.prompt_prefix = Styled::new("mb>") + .with_fg(Color::LightBlue) + .with_attr(Attributes::BOLD); + config.answered_prompt_prefix = Styled::new("->") + .with_fg(Color::LightGreen) + .with_attr(Attributes::BOLD); + config.highlighted_option_prefix = Styled::new(">") + .with_fg(Color::LightCyan) + .with_attr(Attributes::BOLD); + config.selected_checkbox = Styled::new("[x]").with_fg(Color::LightGreen); + config.unselected_checkbox = Styled::new("[ ]").with_fg(Color::DarkGrey); + config.prompt = StyleSheet::new().with_attr(Attributes::BOLD); + config.help_message = StyleSheet::new().with_fg(Color::DarkGrey); + config.answer = StyleSheet::new() + .with_fg(Color::LightCyan) + .with_attr(Attributes::BOLD); + config.selected_option = Some(StyleSheet::new().with_fg(Color::LightBlue)); + config +} + +pub(super) fn collect_setup_plan( + settings: &AppSettings, + secrets: &SecretStore, + detected_agents: &[AgentKind], + model_catalog: &ModelCatalog, +) -> Result { + print_setup_intro(); + + print_setup_section("Basic"); + let namespace = + prompt_namespace(settings.active_namespace()).and_then(WizardStep::into_result)?; + + print_setup_section("LLM configuration"); + let provider = prompt_provider( + settings + .server + .as_ref() + .and_then(|server| server.llm_provider.as_deref()), + ) + .and_then(WizardStep::into_result)?; + let current_ollama_url = settings + .server + .as_ref() + .and_then(|server| server.ollama_url.as_deref()); + let current_model = settings + .server + .as_ref() + .and_then(|server| server.llm_model.as_deref()); + let (ollama_url, model) = if provider == ProviderId::Ollama { + let ollama_url = prompt_ollama_url(current_ollama_url).and_then(WizardStep::into_result)?; + let model = prompt_ollama_model(current_model, &ollama_url, model_catalog) + .and_then(WizardStep::into_result)?; + (Some(ollama_url), model) + } else { + let model = prompt_model(provider, current_model, model_catalog) + .and_then(WizardStep::into_result)?; + (None, model) + }; + let secret_choice = + collect_secret_choice(provider, secrets).and_then(WizardStep::into_result)?; + + print_setup_section("Preferences"); + let autostart = prompt_autostart( + settings + .service + .as_ref() + .and_then(|service| service.autostart), + ) + .and_then(WizardStep::into_result)?; + + print_setup_section("Agent integrations"); + println!( + "{}", + styled_subtle("Choose one or more agents to configure in this setup run.") + ); + let selected_agents = prompt_agents(detected_agents).and_then(WizardStep::into_result)?; + + let mut advanced = AdvancedSettings::from_settings(settings); + let has_existing_advanced = advanced.has_overrides(); + print_setup_section("Advanced settings"); + let configure_advanced = WizardStep::from_option( + Confirm::new("Configure advanced settings?") + .with_default(has_existing_advanced) + .with_help_message( + "Most users can skip this. You can change these later with `mb config` if needed.", + ) + .prompt_skippable()?, + ) + .into_result()?; + + if configure_advanced { + advanced = prompt_advanced_settings(settings).and_then(WizardStep::into_result)?; + } + + Ok(SetupPlan { + namespace, + provider, + model, + ollama_url, + autostart, + selected_agents, + secret_choice, + advanced, + }) +} + +fn prompt_namespace(current: Namespace) -> Result, AppError> { + let default_value = current.to_string(); + Ok(WizardStep::from_option( + Text::new("Active namespace") + .with_default(default_value.as_str()) + .with_help_message( + "This is the user-level memory space the managed service will run against.", + ) + .with_placeholder("default") + .prompt_skippable()? + .map(Namespace::new), + )) +} + +fn prompt_provider(current: Option<&str>) -> Result, AppError> { + let options = ProviderId::ALL.to_vec(); + let default_index = options + .iter() + .position(|choice| *choice == ProviderId::from_config_value(current)) + .unwrap_or(0); + Ok(WizardStep::from_option( + Select::new("LLM provider:", options) + .with_starting_cursor(default_index) + .with_page_size(4) + .with_help_message( + "This powers Memory Bank's internal memory analysis, not the coding agent you use directly.", + ) + .prompt_skippable()?, + )) +} + +fn prompt_ollama_url(current: Option<&str>) -> Result, AppError> { + Ok(WizardStep::from_option( + Text::new("Ollama URL") + .with_default(current.unwrap_or(DEFAULT_OLLAMA_URL)) + .with_help_message( + "Memory Bank will query this Ollama daemon for the local models you already have installed.", + ) + .with_placeholder("http://localhost:11434") + .with_validator(|value: &str| { + Ok(if value.trim().is_empty() { + Validation::Invalid("Ollama URL cannot be empty".into()) + } else { + Validation::Valid + }) + }) + .prompt_skippable()? + .map(|value| normalize_ollama_url(&value)), + )) +} + +fn prompt_model( + provider: ProviderId, + current: Option<&str>, + catalog: &ModelCatalog, +) -> Result, AppError> { + let choices = model_choices_for_provider(provider.as_str(), current, catalog); + let preferred = current + .filter(|value| !value.is_empty()) + .or_else(|| Some(provider.default_model())); + let default_index = preferred + .and_then(|value| { + choices + .iter() + .position(|choice| choice.value() == Some(value)) + }) + .unwrap_or(0); + let prompt = format!("Model for {}:", provider); + let selection = Select::new(&prompt, choices) + .with_starting_cursor(default_index) + .with_page_size(8) + .with_help_message( + "Choose a popular model ID for this provider. If you need a different one, pick the custom option and type it exactly.", + ) + .prompt_skippable()?; + + let Some(selection) = selection else { + return Ok(WizardStep::Canceled); + }; + + match selection { + ModelChoice::Preset(model) | ModelChoice::Current(model) => Ok(WizardStep::Continue(model)), + ModelChoice::Custom => Ok(WizardStep::from_option( + Text::new("Custom model string") + .with_default(current.unwrap_or(provider.default_model())) + .with_help_message("Enter the exact model ID for the selected provider.") + .with_validator(|value: &str| { + Ok(if value.trim().is_empty() { + Validation::Invalid("Model ID cannot be empty".into()) + } else { + Validation::Valid + }) + }) + .prompt_skippable()? + .map(|value| value.trim().to_string()), + )), + } +} + +fn prompt_ollama_model( + current: Option<&str>, + ollama_url: &str, + catalog: &ModelCatalog, +) -> Result, AppError> { + match fetch_ollama_models_for_setup(ollama_url) { + Ok(models) if !models.is_empty() => { + let choices = model_choices_from_values(&models, current); + let preferred = current + .filter(|value| !value.is_empty()) + .or_else(|| Some(ProviderId::Ollama.default_model())); + let default_index = preferred + .and_then(|value| { + choices + .iter() + .position(|choice| choice.value() == Some(value)) + }) + .unwrap_or(0); + let selection = Select::new("Model for Ollama (installed locally):", choices) + .with_starting_cursor(default_index) + .with_page_size(10) + .with_help_message( + "These models were discovered from your Ollama daemon. If yours is missing, choose the custom option.", + ) + .prompt_skippable()?; + + let Some(selection) = selection else { + return Ok(WizardStep::Canceled); + }; + + match selection { + ModelChoice::Preset(model) | ModelChoice::Current(model) => { + Ok(WizardStep::Continue(model)) + } + ModelChoice::Custom => prompt_custom_ollama_model(current, catalog), + } + } + Ok(_) => { + println!( + "{}", + styled_warning(&format!( + "No local Ollama models were detected at {}.", + ollama_url.trim_end_matches('/') + )) + ); + prompt_custom_ollama_model(current, catalog) + } + Err(error) => { + println!( + "{}", + styled_warning(&format!("Could not query Ollama at {ollama_url}: {error}")) + ); + prompt_custom_ollama_model(current, catalog) + } + } +} + +fn prompt_custom_ollama_model( + current: Option<&str>, + catalog: &ModelCatalog, +) -> Result, AppError> { + let suggestions = catalog.models_for_provider("ollama"); + let help = if suggestions.is_empty() { + "Enter the local Ollama model name you want Memory Bank to use." + } else { + "Enter the local Ollama model name you want Memory Bank to use. Common pulls: qwen3, deepseek-r1, llama3.1, qwen2.5-coder." + }; + Ok(WizardStep::from_option( + Text::new("Ollama model name:") + .with_default(current.unwrap_or(ProviderId::Ollama.default_model())) + .with_help_message(help) + .with_validator(|value: &str| { + Ok(if value.trim().is_empty() { + Validation::Invalid("Model name cannot be empty".into()) + } else { + Validation::Valid + }) + }) + .prompt_skippable()? + .map(|value| value.trim().to_string()), + )) +} + +fn prompt_autostart(current: Option) -> Result, AppError> { + Ok(WizardStep::from_option( + Confirm::new("Start Memory Bank automatically on login?") + .with_default(current.unwrap_or(true)) + .with_help_message("This installs a user-scoped background service for Memory Bank.") + .prompt_skippable()?, + )) +} + +fn prompt_agents(detected: &[AgentKind]) -> Result>, AppError> { + if detected.is_empty() { + println!( + "{}", + styled_warning( + "No supported agents were detected on PATH. You can rerun `mb setup` later after installing Claude Code, Gemini CLI, OpenCode, or OpenClaw." + ) + ); + return Ok(WizardStep::Continue(Vec::new())); + } + + Ok(WizardStep::from_option( + MultiSelect::new( + "Select which detected agents to configure now", + detected.to_vec(), + ) + .with_all_selected_by_default() + .with_page_size(detected.len().min(7)) + .with_help_message( + "Use Space to toggle the highlighted agent. Press Enter to continue with all checked agents.", + ) + .prompt_skippable()?, + )) +} + +fn collect_secret_choice( + provider: ProviderId, + secrets: &SecretStore, +) -> Result, AppError> { + let plan = secret_prompt_plan( + provider, + provider + .secret_env_key() + .and_then(|key| std::env::var(key).ok()), + provider + .secret_env_key() + .and_then(|key| secrets.get(key).map(str::to_owned)), + ); + + match plan { + SecretPromptPlan::NotRequired => Ok(WizardStep::Continue(SecretChoice::NotRequired)), + SecretPromptPlan::OfferEnvironment { key, value } => { + let use_env = WizardStep::from_option( + Confirm::new(&format!( + "Store and use the current shell {key} for Memory Bank?" + )) + .with_default(true) + .with_help_message( + "This writes the key to ~/.memory_bank/secrets.env so the managed service uses the same provider secret every time it starts.", + ) + .prompt_skippable()?, + ) + .into_result()?; + if use_env { + Ok(WizardStep::Continue(SecretChoice::UseEnvironment { + key, + value, + })) + } else { + manual_secret_choice(key) + } + } + SecretPromptPlan::OfferStored { key } => { + let keep = WizardStep::from_option( + Confirm::new(&format!( + "Keep using the stored {key} from ~/.memory_bank/secrets.env?" + )) + .with_default(true) + .with_help_message( + "Answer no if you want to replace it with a different key during this setup run.", + ) + .prompt_skippable()?, + ) + .into_result()?; + if keep { + Ok(WizardStep::Continue(SecretChoice::KeepStored { key })) + } else { + manual_secret_choice(key) + } + } + SecretPromptPlan::ManualEntry { key } => manual_secret_choice(key), + } +} + +fn secret_prompt_plan( + provider: ProviderId, + env_value: Option, + stored_value: Option, +) -> SecretPromptPlan { + let Some(secret_key) = provider.secret_env_key() else { + return SecretPromptPlan::NotRequired; + }; + + match ( + env_value.filter(|value| !value.trim().is_empty()), + stored_value.filter(|value| !value.trim().is_empty()), + ) { + (Some(value), _) => SecretPromptPlan::OfferEnvironment { + key: secret_key, + value, + }, + (None, Some(_)) => SecretPromptPlan::OfferStored { key: secret_key }, + (None, None) => SecretPromptPlan::ManualEntry { key: secret_key }, + } +} + +fn manual_secret_choice(secret_key: &'static str) -> Result, AppError> { + match Text::new(&format!("Enter {secret_key}:")) + .with_help_message( + "This will be stored in ~/.memory_bank/secrets.env for the managed service. Input is shown as you type.", + ) + .with_validator(|value: &str| { + Ok(if value.trim().is_empty() { + Validation::Invalid("Secret value cannot be empty".into()) + } else { + Validation::Valid + }) + }) + .prompt_skippable()? + { + Some(value) if !value.trim().is_empty() => Ok(WizardStep::Continue( + SecretChoice::ManualEntry { + key: secret_key, + value: value.trim().to_string(), + }, + )), + Some(_) => Err(AppError::MissingProviderSecret(secret_key)), + None => Ok(WizardStep::Canceled), + } +} + +fn prompt_advanced_settings( + settings: &AppSettings, +) -> Result, AppError> { + let current = AdvancedSettings::from_settings(settings); + + let port = WizardStep::from_option( + CustomType::::new("Port") + .with_default(current.port) + .with_help_message("Local HTTP port for /mcp, /ingest, and /healthz.") + .with_validator(|value: &u16| { + Ok(if *value == 0 { + Validation::Invalid("Port must be between 1 and 65535".into()) + } else { + Validation::Valid + }) + }) + .prompt_skippable()?, + ) + .into_result()?; + + let fastembed_model = WizardStep::from_option( + Text::new("FastEmbed model override") + .with_default(current.fastembed_model.as_str()) + .with_help_message( + "Leave this at the default Jina model unless you know you want a different FastEmbed-compatible model.", + ) + .with_validator(|value: &str| { + Ok(if value.trim().is_empty() { + Validation::Invalid("FastEmbed model cannot be empty".into()) + } else { + Validation::Valid + }) + }) + .prompt_skippable()?, + ) + .into_result()? + .trim() + .to_string(); + + let history_window_size = WizardStep::from_option( + CustomType::::new("History window size") + .with_default(current.history_window_size) + .with_help_message("0 means unlimited prior turns during memory analysis.") + .prompt_skippable()?, + ) + .into_result()?; + + let nearest_neighbor_count = WizardStep::from_option( + CustomType::::new("Nearest neighbor count") + .with_default(current.nearest_neighbor_count) + .with_help_message("How many nearest matches to load during recall and graph updates.") + .with_validator(|value: &i32| { + Ok(if *value >= 1 { + Validation::Valid + } else { + Validation::Invalid("Nearest neighbor count must be at least 1".into()) + }) + }) + .prompt_skippable()?, + ) + .into_result()?; + + Ok(WizardStep::Continue(AdvancedSettings { + port, + fastembed_model, + history_window_size, + nearest_neighbor_count, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::command_utils::yes_no; + + #[test] + fn secret_prompt_plan_prefers_shell_key_over_stored_secret() { + let plan = secret_prompt_plan( + ProviderId::Anthropic, + Some("from-shell".to_string()), + Some("stored".to_string()), + ); + + assert_eq!( + plan, + SecretPromptPlan::OfferEnvironment { + key: "ANTHROPIC_API_KEY", + value: "from-shell".to_string() + } + ); + } + + #[test] + fn secret_prompt_plan_uses_stored_secret_when_shell_key_is_missing() { + let plan = secret_prompt_plan(ProviderId::Gemini, None, Some("stored".to_string())); + + assert_eq!( + plan, + SecretPromptPlan::OfferStored { + key: "GEMINI_API_KEY" + } + ); + } + + #[test] + fn secret_prompt_plan_requires_manual_entry_when_no_secret_exists() { + let plan = secret_prompt_plan(ProviderId::OpenAi, None, None); + + assert_eq!( + plan, + SecretPromptPlan::ManualEntry { + key: "OPENAI_API_KEY" + } + ); + } + + #[test] + fn secret_prompt_plan_ignores_blank_shell_and_stored_values() { + let plan = secret_prompt_plan( + ProviderId::Gemini, + Some(" ".to_string()), + Some(String::new()), + ); + + assert_eq!( + plan, + SecretPromptPlan::ManualEntry { + key: "GEMINI_API_KEY" + } + ); + } + + #[test] + fn secret_prompt_plan_skips_secret_flow_for_ollama() { + let plan = secret_prompt_plan(ProviderId::Ollama, Some("ignored".to_string()), None); + + assert_eq!(plan, SecretPromptPlan::NotRequired); + } + + #[test] + fn wizard_step_maps_prompt_skips_to_setup_canceled() { + let error = WizardStep::::Canceled + .into_result() + .expect_err("skip should cancel"); + + assert!(matches!(error, AppError::SetupCanceled)); + } + + #[test] + fn yes_no_still_matches_existing_wording() { + assert_eq!(yes_no(true), "yes"); + assert_eq!(yes_no(false), "no"); + } +} diff --git a/memory-bank-cli/src/setup/render.rs b/memory-bank-cli/src/setup/render.rs new file mode 100644 index 0000000..b76c553 --- /dev/null +++ b/memory-bank-cli/src/setup/render.rs @@ -0,0 +1,162 @@ +use crate::agents::AgentKind; +use crate::assets::ExposureOutcome; +use crate::command_utils::yes_no; +use crate::output::{styled_command, styled_section, styled_subtle, styled_title}; + +use super::plan::SetupPlan; + +pub(super) fn print_setup_intro() { + println!("{}", styled_title("Memory Bank Setup")); + println!( + "{}", + styled_subtle( + "Configure the local Memory Bank service and any detected agent integrations." + ) + ); + println!( + "{}", + styled_subtle("You will review everything before any changes are applied.") + ); +} + +pub(super) fn print_setup_section(title: &str) { + println!(); + println!("{}", styled_section(title)); +} + +pub(super) fn render_review_summary(plan: &SetupPlan) -> String { + let mut lines = vec![ + styled_section("Setup review"), + " Basic".to_string(), + format!(" Namespace: {}", plan.namespace), + String::new(), + " LLM configuration".to_string(), + format!(" Provider: {}", plan.provider), + format!(" Model: {}", plan.model), + format!(" Secret: {}", plan.secret_choice.summary()), + String::new(), + " Preferences".to_string(), + format!(" Autostart: {}", yes_no(plan.autostart)), + String::new(), + " Agent integrations".to_string(), + format!( + " Selected: {}", + render_agents_summary(&plan.selected_agents) + ), + ]; + + if let Some(url) = plan.ollama_url.as_deref() { + lines.insert(6, format!(" Ollama URL: {url}")); + } + + let overrides = plan.advanced.override_lines(); + lines.push(String::new()); + lines.push(" Advanced settings".to_string()); + if overrides.is_empty() { + lines.push(" Using defaults".to_string()); + } else { + for line in overrides { + lines.push(format!(" {line}")); + } + } + + lines.join("\n") +} + +pub(super) fn render_post_setup_help(exposure: &ExposureOutcome) -> String { + [ + styled_section("What's next"), + "A few useful commands after setup:".to_string(), + format!( + " {} Review the saved configuration", + styled_command(&format!("{} config show", exposure.command_prefix)) + ), + format!( + " {} Run the guided setup again any time", + styled_command(&format!("{} setup", exposure.command_prefix)) + ), + format!( + " {} Check for common install or config issues", + styled_command(&format!("{} doctor", exposure.command_prefix)) + ), + ] + .join("\n") +} + +pub(super) fn render_agents_summary(agents: &[AgentKind]) -> String { + if agents.is_empty() { + "none selected".to_string() + } else { + agents + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + } +} + +#[cfg(test)] +mod tests { + use super::super::plan::{AdvancedSettings, SecretChoice}; + use super::*; + use crate::assets::ExposureMode; + use crate::domain::ProviderId; + use memory_bank_app::{AppSettings, Namespace}; + + #[test] + fn render_review_summary_hides_secret_value_and_omits_default_advanced() { + let plan = SetupPlan { + namespace: Namespace::new("default"), + provider: ProviderId::Anthropic, + model: memory_bank_app::DEFAULT_ANTHROPIC_MODEL.to_string(), + ollama_url: None, + autostart: true, + selected_agents: vec![AgentKind::ClaudeCode, AgentKind::GeminiCli], + secret_choice: SecretChoice::ManualEntry { + key: "ANTHROPIC_API_KEY", + value: "super-secret".to_string(), + }, + advanced: AdvancedSettings::from_settings(&AppSettings::default()), + }; + + let summary = render_review_summary(&plan); + assert!(summary.contains("Store a newly entered ANTHROPIC_API_KEY")); + assert!(!summary.contains("super-secret")); + assert!(!summary.contains("Advanced overrides")); + } + + #[test] + fn render_post_setup_help_mentions_key_commands() { + let help = render_post_setup_help(&ExposureOutcome { + mode: ExposureMode::Launcher, + bare_command_works_now: true, + command_prefix: "mb".to_string(), + }); + + assert!(help.contains("mb config show")); + assert!(help.contains("mb setup")); + assert!(help.contains("mb doctor")); + } + + #[test] + fn render_post_setup_help_uses_absolute_path_when_bare_mb_is_not_ready() { + let help = render_post_setup_help(&ExposureOutcome { + mode: ExposureMode::ShellInitFallback, + bare_command_works_now: false, + command_prefix: "/tmp/.memory_bank/bin/mb".to_string(), + }); + + assert!(help.contains("/tmp/.memory_bank/bin/mb config show")); + assert!(help.contains("/tmp/.memory_bank/bin/mb setup")); + assert!(help.contains("/tmp/.memory_bank/bin/mb doctor")); + } + + #[test] + fn render_agents_summary_handles_empty_and_multiple_values() { + assert_eq!(render_agents_summary(&[]), "none selected"); + assert_eq!( + render_agents_summary(&[AgentKind::ClaudeCode, AgentKind::OpenClaw]), + "Claude Code, OpenClaw" + ); + } +} diff --git a/memory-bank-cli/tests/mb_blackbox.rs b/memory-bank-cli/tests/mb_blackbox.rs new file mode 100644 index 0000000..9092186 --- /dev/null +++ b/memory-bank-cli/tests/mb_blackbox.rs @@ -0,0 +1,1182 @@ +use memory_bank_app::AppPaths; +use std::env; +use std::fs; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::thread; +use tempfile::TempDir; + +const MB_BINARY: &str = env!("CARGO_BIN_EXE_mb"); + +struct MbHarness { + _home: TempDir, + _cwd: TempDir, + _shim_dir: TempDir, + paths: AppPaths, + cwd: PathBuf, + shim_log: PathBuf, + path: String, +} + +struct HealthzServer { + port: u16, + stop: Arc, + handle: Option>, +} + +impl MbHarness { + fn new() -> Self { + let home = TempDir::new().expect("temp home"); + let cwd = TempDir::new().expect("temp cwd"); + let shim_dir = TempDir::new().expect("temp shims"); + let paths = AppPaths::from_home_dir(home.path().to_path_buf()); + paths.ensure_base_dirs().expect("base dirs"); + fs::create_dir_all(home.path().join(".config")).expect("xdg config"); + fs::create_dir_all(home.path().join(".local/state")).expect("xdg state"); + fs::create_dir_all(home.path().join(".local/share")).expect("xdg data"); + let shim_log = home.path().join("shim-invocations.log"); + let cwd_path = cwd.path().to_path_buf(); + + write_shim( + &shim_dir.path().join("tail"), + &format!( + r#"#!/bin/sh +printf '%s\n' "tail $*" >> '{}' +last="" +for arg in "$@"; do + last="$arg" +done +[ -n "$last" ] && /bin/cat "$last" +exit 0 +"#, + shim_log.display() + ), + ); + + #[cfg(target_os = "macos")] + { + write_shim( + &shim_dir.path().join("launchctl"), + &format!( + r#"#!/bin/sh +printf '%s\n' "launchctl $*" >> '{}' +case "$1" in + print) + state_file="{}" + if [ -n "${{MB_TEST_LAUNCHCTL_PRINT_SEQUENCE:-}}" ]; then + if [ ! -f "$state_file" ]; then + printf '%s' "${{MB_TEST_LAUNCHCTL_PRINT_SEQUENCE}}" > "$state_file" + fi + sequence="$(cat "$state_file")" + case "$sequence" in + *,*) + code="${{sequence%%,*}}" + printf '%s' "${{sequence#*,}}" > "$state_file" + ;; + *) + code="$sequence" + ;; + esac + else + code="${{MB_TEST_LAUNCHCTL_PRINT_EXIT:-1}}" + fi + if [ "$code" -eq 0 ]; then + pid_state_file="{}" + if [ -n "${{MB_TEST_LAUNCHCTL_PID_SEQUENCE:-}}" ]; then + if [ ! -f "$pid_state_file" ]; then + printf '%s' "${{MB_TEST_LAUNCHCTL_PID_SEQUENCE}}" > "$pid_state_file" + fi + pid_sequence="$(cat "$pid_state_file")" + case "$pid_sequence" in + *,*) + pid="${{pid_sequence%%,*}}" + printf '%s' "${{pid_sequence#*,}}" > "$pid_state_file" + ;; + *) + pid="$pid_sequence" + ;; + esac + else + pid="${{MB_TEST_LAUNCHCTL_PID:-4321}}" + fi + printf 'pid = %s\n' "$pid" + fi + exit "$code" + ;; + bootstrap|kickstart|bootout) + exit 0 + ;; + *) + exit 0 + ;; +esac +"#, + shim_log.display(), + home.path().join("launchctl-print-sequence-state").display(), + home.path().join("launchctl-pid-sequence-state").display() + ), + ); + write_shim( + &shim_dir.path().join("id"), + &format!( + r#"#!/bin/sh +printf '%s\n' "id $*" >> '{}' +printf '%s\n' "${{MB_TEST_UID:-501}}" +"#, + shim_log.display() + ), + ); + } + + #[cfg(target_os = "linux")] + { + write_shim( + &shim_dir.path().join("systemctl"), + &format!( + r#"#!/bin/sh +printf '%s\n' "systemctl $*" >> '{}' +case "$1 $2 $3" in + "--user is-active --quiet") + state_file="{}" + if [ -n "${{MB_TEST_SYSTEMCTL_IS_ACTIVE_SEQUENCE:-}}" ]; then + if [ ! -f "$state_file" ]; then + printf '%s' "${{MB_TEST_SYSTEMCTL_IS_ACTIVE_SEQUENCE}}" > "$state_file" + fi + sequence="$(cat "$state_file")" + case "$sequence" in + *,*) + code="${{sequence%%,*}}" + printf '%s' "${{sequence#*,}}" > "$state_file" + ;; + *) + code="$sequence" + ;; + esac + else + code="${{MB_TEST_SYSTEMCTL_IS_ACTIVE_EXIT:-1}}" + fi + exit "$code" + ;; + "--user show memory-bank.service") + state_file="{}" + if [ -n "${{MB_TEST_SYSTEMCTL_MAINPID_SEQUENCE:-}}" ]; then + if [ ! -f "$state_file" ]; then + printf '%s' "${{MB_TEST_SYSTEMCTL_MAINPID_SEQUENCE}}" > "$state_file" + fi + sequence="$(cat "$state_file")" + case "$sequence" in + *,*) + pid="${{sequence%%,*}}" + printf '%s' "${{sequence#*,}}" > "$state_file" + ;; + *) + pid="$sequence" + ;; + esac + else + pid="${{MB_TEST_SYSTEMCTL_MAINPID:-0}}" + fi + printf '%s\n' "$pid" + exit 0 + ;; + *) + exit 0 + ;; +esac +"#, + shim_log.display(), + home.path() + .join("systemctl-is-active-sequence-state") + .display(), + home.path() + .join("systemctl-mainpid-sequence-state") + .display() + ), + ); + } + + let path = format!("{}:/usr/bin:/bin", shim_dir.path().display()); + + Self { + _home: home, + _cwd: cwd, + _shim_dir: shim_dir, + paths, + cwd: cwd_path, + shim_log, + path, + } + } + + fn run(&self, args: &[&str]) -> Output { + self.run_with_env(args, &[]) + } + + fn run_with_env(&self, args: &[&str], extra_env: &[(&str, &str)]) -> Output { + let mut command = Command::new(MB_BINARY); + command.args(args); + command.current_dir(&self.cwd); + command.env("HOME", &self.paths.home_dir); + command.env("PATH", &self.path); + command.env("NO_COLOR", "1"); + command.env("CI", "1"); + command.env("TERM", "dumb"); + command.env("SHELL", "/bin/zsh"); + command.env("XDG_CONFIG_HOME", self.paths.home_dir.join(".config")); + command.env("XDG_STATE_HOME", self.paths.home_dir.join(".local/state")); + command.env("XDG_DATA_HOME", self.paths.home_dir.join(".local/share")); + command.env_remove("OPENCLAW_STATE_DIR"); + command.env_remove("OPENCLAW_CONFIG_PATH"); + command.env_remove("OPENCLAW_CONTAINER"); + for (key, value) in extra_env { + command.env(key, value); + } + command.output().expect("run mb") + } + + fn seed_service_binary_placeholders(&self) { + for binary in [ + "memory-bank-server", + "memory-bank-hook", + "memory-bank-mcp-proxy", + ] { + let path = self.paths.binary_path(binary); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("binary dir"); + } + fs::write(&path, "#!/bin/sh\nexit 0\n").expect("placeholder binary"); + #[cfg(unix)] + { + let mut permissions = fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("permissions"); + } + } + } + + fn write_log_file(&self, contents: &str) { + if let Some(parent) = self.paths.log_file.parent() { + fs::create_dir_all(parent).expect("logs dir"); + } + fs::write(&self.paths.log_file, contents).expect("log file"); + } + + fn write_startup_state(&self, state: &memory_bank_app::ServerStartupState) { + let namespace = memory_bank_app::Namespace::new(&state.namespace); + let path = self.paths.server_startup_state_path(&namespace); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("startup state dir"); + } + fs::write( + path, + serde_json::to_vec_pretty(state).expect("startup state json"), + ) + .expect("startup state file"); + } + + fn read_shim_log(&self) -> String { + fs::read_to_string(&self.shim_log).unwrap_or_default() + } +} + +impl HealthzServer { + fn new(namespace: &str, provider: &str, encoder: &str, version: &str) -> Self { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind health server"); + let server_port = listener.local_addr().expect("local addr").port(); + listener + .set_nonblocking(true) + .expect("set nonblocking health listener"); + let body = format!( + "{{\"ok\":true,\"namespace\":\"{namespace}\",\"port\":{server_port},\"llm_provider\":\"{provider}\",\"encoder_provider\":\"{encoder}\",\"version\":\"{version}\"}}" + ); + let stop = Arc::new(AtomicBool::new(false)); + let stop_thread = Arc::clone(&stop); + let handle = thread::spawn(move || { + while !stop_thread.load(Ordering::Relaxed) { + match listener.accept() { + Ok((mut stream, _)) => { + let mut buffer = [0_u8; 1024]; + let _ = stream.read(&mut buffer); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(std::time::Duration::from_millis(10)); + } + Err(_) => break, + } + } + }); + + Self { + port: server_port, + stop, + handle: Some(handle), + } + } + + fn port(&self) -> u16 { + self.port + } +} + +impl Drop for HealthzServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + let _ = TcpStream::connect(("127.0.0.1", self.port)); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +fn write_shim(path: &Path, contents: &str) { + fs::write(path, contents).expect("shim file"); + #[cfg(unix)] + { + let mut permissions = fs::metadata(path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("permissions"); + } +} + +fn stdout_string(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +} + +fn stderr_string(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} + +fn assert_contains_all(haystack: &str, needles: &[&str]) { + for needle in needles { + assert!( + haystack.contains(needle), + "expected help output to contain `{needle}`\n\n{haystack}" + ); + } +} + +fn count_occurrences(haystack: &str, needle: &str) -> usize { + haystack.matches(needle).count() +} + +#[test] +fn mb_top_level_help_describes_commands_and_quick_start() { + let harness = MbHarness::new(); + + let output = harness.run(&["--help"]); + assert!(output.status.success(), "{}", stderr_string(&output)); + + let help = stdout_string(&output); + assert_contains_all( + &help, + &[ + "Run guided setup for provider, service, and agent integrations", + "Check current Memory Bank health and configuration", + "Diagnose common install and configuration problems", + "Read the managed service log", + "Manage memory namespaces", + "Manage the user-scoped background service", + "Inspect and edit saved configuration", + "Quick start:", + "mb setup", + "mb doctor --fix", + "mb config show", + ], + ); +} + +#[test] +fn mb_help_for_status_doctor_and_logs_explains_behavior_and_flags() { + let harness = MbHarness::new(); + + let status = harness.run(&["status", "--help"]); + assert!(status.status.success(), "{}", stderr_string(&status)); + assert_contains_all( + &stdout_string(&status), + &[ + "Show the current Memory Bank status.", + "reports the active namespace", + "mb doctor", + "mb logs", + ], + ); + + let doctor = harness.run(&["doctor", "--help"]); + assert!(doctor.status.success(), "{}", stderr_string(&doctor)); + assert_contains_all( + &stdout_string(&doctor), + &[ + "Diagnose common install and configuration problems.", + "Doctor checks CLI exposure", + "Attempt safe repairs such as exposing `mb`", + "mb doctor --fix", + ], + ); + + let logs = harness.run(&["logs", "--help"]); + assert!(logs.status.success(), "{}", stderr_string(&logs)); + assert_contains_all( + &stdout_string(&logs), + &[ + "Read the managed service log at `~/.memory_bank/logs/server.log`.", + "Keep streaming `~/.memory_bank/logs/server.log` as new lines arrive", + "mb logs --follow", + ], + ); +} + +#[test] +fn mb_help_for_namespace_service_and_config_includes_examples_and_guidance() { + let harness = MbHarness::new(); + + let namespace = harness.run(&["namespace", "--help"]); + assert!(namespace.status.success(), "{}", stderr_string(&namespace)); + assert_contains_all( + &stdout_string(&namespace), + &[ + "Manage Memory Bank namespaces.", + "List known namespaces and mark the active one", + "Switch the active namespace", + "Namespace names are sanitized", + "mb namespace use work-project", + ], + ); + + let service = harness.run(&["service", "--help"]); + assert!(service.status.success(), "{}", stderr_string(&service)); + assert_contains_all( + &stdout_string(&service), + &[ + "Manage the user-scoped Memory Bank background service.", + "launchd on macOS and systemd --user on Linux", + "Install the managed service definition", + "Read the managed service log", + "mb service logs --follow", + "`mb service start` installs the service definition first if it is missing.", + ], + ); + + let config = harness.run(&["config", "--help"]); + assert!(config.status.success(), "{}", stderr_string(&config)); + assert_contains_all( + &stdout_string(&config), + &[ + "Inspect and edit saved Memory Bank settings.", + "Configuration is stored in `~/.memory_bank/settings.toml`.", + "service.port", + "server.llm_provider", + "integrations.openclaw.configured", + "Default namespace: default", + "server.llm_provider: anthropic | gemini | open-ai | ollama", + "mb config set server.llm_provider gemini", + ], + ); +} + +#[test] +fn mb_nested_help_describes_arguments_and_non_obvious_behavior() { + let harness = MbHarness::new(); + + let namespace_use = harness.run(&["namespace", "use", "--help"]); + assert!( + namespace_use.status.success(), + "{}", + stderr_string(&namespace_use) + ); + assert_contains_all( + &stdout_string(&namespace_use), + &[ + "Switch the active namespace.", + "restarts it when already running or starts it when stopped", + "Namespace name to activate", + ], + ); + + let service_install = harness.run(&["service", "install", "--help"]); + assert!( + service_install.status.success(), + "{}", + stderr_string(&service_install) + ); + assert_contains_all( + &stdout_string(&service_install), + &[ + "Install the user-scoped Memory Bank service definition for the current platform.", + "launchd agent on macOS or a systemd --user unit on Linux", + ], + ); + + let config_set = harness.run(&["config", "set", "--help"]); + assert!( + config_set.status.success(), + "{}", + stderr_string(&config_set) + ); + assert_contains_all( + &stdout_string(&config_set), + &[ + "Update a single saved config value in `~/.memory_bank/settings.toml`.", + "Config key to update. Run `mb config --help` to see supported keys", + "New value for the selected key", + "server.encoder_provider: fast-embed | local-api | remote-api", + "mb config set server.llm_model \"\"", + "Use an empty string to clear optional string overrides", + ], + ); +} + +#[test] +fn mb_config_round_trips_values_through_the_binary() { + let harness = MbHarness::new(); + + let set_namespace = harness.run(&["config", "set", "active_namespace", "team a/1"]); + assert!( + set_namespace.status.success(), + "{}", + stderr_string(&set_namespace) + ); + assert_contains_all( + &stdout_string(&set_namespace), + &[ + "Updated `active_namespace`.", + "Old value: default", + "New value: team_a_1", + "next time the managed service starts", + ], + ); + + let set_port = harness.run(&["config", "set", "service.port", "4545"]); + assert!(set_port.status.success(), "{}", stderr_string(&set_port)); + assert_contains_all( + &stdout_string(&set_port), + &[ + "Updated `service.port`.", + "Old value: 3737", + "New value: 4545", + "next time the managed service starts", + ], + ); + + let get_namespace = harness.run(&["config", "get", "active_namespace"]); + assert!( + get_namespace.status.success(), + "{}", + stderr_string(&get_namespace) + ); + assert_eq!(stdout_string(&get_namespace).trim(), "team_a_1"); + + let get_port = harness.run(&["config", "get", "service.port"]); + assert!(get_port.status.success(), "{}", stderr_string(&get_port)); + assert_eq!(stdout_string(&get_port).trim(), "4545"); + + let show = harness.run(&["config", "show"]); + assert!(show.status.success(), "{}", stderr_string(&show)); + let rendered = stdout_string(&show); + assert!(rendered.contains("active_namespace = \"team_a_1\"")); + assert!(rendered.contains("[service]")); + assert!(rendered.contains("port = 4545")); +} + +#[test] +fn mb_config_set_fastembed_model_requires_confirmation_without_yes() { + let harness = MbHarness::new(); + + let output = harness.run(&[ + "config", + "set", + "server.fastembed_model", + "custom/embed-model", + ]); + + assert!(!output.status.success()); + assert_contains_all( + &stderr_string(&output), + &[ + "Changing the FastEmbed model will rebuild the vector index on startup and re-encode existing memories for this namespace.", + "pass `--yes` to accept it", + ], + ); +} + +#[test] +fn mb_config_set_fastembed_model_accepts_yes_flag() { + let harness = MbHarness::new(); + + let output = harness.run(&[ + "config", + "set", + "--yes", + "server.fastembed_model", + "custom/embed-model", + ]); + + assert!(output.status.success(), "{}", stderr_string(&output)); + assert_contains_all( + &stdout_string(&output), + &[ + "Updated `server.fastembed_model`.", + "Old value: jinaai/jina-embeddings-v2-base-code", + "New value: custom/embed-model", + "rebuild the vector index and re-encode existing memories", + ], + ); +} + +#[test] +fn mb_namespace_commands_manage_sanitized_names_without_starting_services() { + let harness = MbHarness::new(); + + let created = harness.run(&["namespace", "create", "team a/1"]); + assert!(created.status.success(), "{}", stderr_string(&created)); + assert_contains_all( + &stdout_string(&created), + &[ + "Created namespace `team_a_1`.", + "Directory:", + "Warning: Requested name `team a/1` was sanitized to `team_a_1`.", + ], + ); + + let used = harness.run_with_env( + &["namespace", "use", "team a/1"], + &[ + #[cfg(target_os = "macos")] + ("MB_TEST_LAUNCHCTL_PRINT_EXIT", "1"), + #[cfg(target_os = "linux")] + ("MB_TEST_SYSTEMCTL_IS_ACTIVE_EXIT", "1"), + ], + ); + assert!(used.status.success(), "{}", stderr_string(&used)); + assert_contains_all( + &stdout_string(&used), + &[ + "Active namespace is now `team_a_1`.", + "Warning: The managed service is not installed, so this namespace will apply on the next service start.", + "mb service start", + ], + ); + + let current = harness.run(&["namespace", "current"]); + assert!(current.status.success(), "{}", stderr_string(¤t)); + assert_eq!(stdout_string(¤t).trim(), "team_a_1"); + + let list = harness.run(&["namespace", "list"]); + assert!(list.status.success(), "{}", stderr_string(&list)); + let listing = stdout_string(&list); + assert!(listing.contains("team_a_1 (active)")); + assert_eq!(listing.matches("team_a_1").count(), 1); + + assert!( + harness + .paths + .namespace_dir(&memory_bank_app::Namespace::new("team a/1")) + .is_dir() + ); +} + +#[test] +fn mb_logs_reads_the_log_file_through_the_tail_shim() { + let harness = MbHarness::new(); + harness.write_log_file("line one\nline two\n"); + + let output = harness.run(&["logs"]); + assert!(output.status.success(), "{}", stderr_string(&output)); + assert!(stdout_string(&output).contains("line one")); + assert!(stdout_string(&output).contains("line two")); + assert!(harness.read_shim_log().contains("tail -n 200")); +} + +#[test] +fn mb_setup_fails_cleanly_without_a_tty() { + let harness = MbHarness::new(); + + let output = harness.run(&["setup"]); + + assert!(!output.status.success()); + assert!(stderr_string(&output).contains("interactive terminal")); +} + +#[test] +fn mb_internal_bootstrap_install_creates_a_managed_launcher() { + let harness = MbHarness::new(); + harness.seed_service_binary_placeholders(); + let launcher_dir = harness.paths.home_dir.join(".local/bin"); + let path = format!( + "{launcher_dir}:/usr/bin:/bin", + launcher_dir = launcher_dir.display() + ); + + let output = harness.run_with_env( + &["internal", "bootstrap-install"], + &[("PATH", &path), ("SHELL", "/bin/bash")], + ); + + assert!(output.status.success(), "{}", stderr_string(&output)); + let launcher = fs::read_to_string(launcher_dir.join("mb")).expect("launcher"); + assert!(launcher.contains("Memory Bank managed launcher")); + assert!(stdout_string(&output).contains("managed `mb` launcher")); +} + +#[test] +fn mb_internal_bootstrap_install_falls_back_to_managed_shell_init() { + let harness = MbHarness::new(); + harness.seed_service_binary_placeholders(); + + let output = harness.run_with_env( + &["internal", "bootstrap-install"], + &[("PATH", "/usr/bin:/bin"), ("SHELL", "/bin/zsh")], + ); + + assert!(output.status.success(), "{}", stderr_string(&output)); + let env_file = fs::read_to_string(harness.paths.root.join("env.sh")).expect("env file"); + assert!(env_file.contains("Memory Bank managed environment")); + let zprofile = fs::read_to_string(harness.paths.home_dir.join(".zprofile")).expect("zprofile"); + let zshrc = fs::read_to_string(harness.paths.home_dir.join(".zshrc")).expect("zshrc"); + assert!(zprofile.contains("# >>> Memory Bank >>>")); + assert!(zshrc.contains("# >>> Memory Bank >>>")); +} + +#[test] +fn mb_internal_bootstrap_install_fails_on_unrelated_mb_collision() { + let harness = MbHarness::new(); + harness.seed_service_binary_placeholders(); + let launcher_dir = harness.paths.home_dir.join(".local/bin"); + fs::create_dir_all(&launcher_dir).expect("launcher dir"); + let collision = launcher_dir.join("mb"); + fs::write(&collision, "#!/bin/sh\nexit 0\n").expect("collision"); + #[cfg(unix)] + { + let mut permissions = fs::metadata(&collision).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&collision, permissions).expect("permissions"); + } + let path = format!( + "{launcher_dir}:/usr/bin:/bin", + launcher_dir = launcher_dir.display() + ); + + let output = harness.run_with_env( + &["internal", "bootstrap-install"], + &[("PATH", &path), ("SHELL", "/bin/bash")], + ); + + assert!(!output.status.success()); + assert!( + stderr_string(&output).contains("another executable already exists on PATH"), + "{}", + stderr_string(&output) + ); +} + +#[test] +fn mb_doctor_fix_provisions_cli_exposure() { + let harness = MbHarness::new(); + harness.seed_service_binary_placeholders(); + let launcher_dir = harness.paths.home_dir.join(".local/bin"); + let path = format!( + "{launcher_dir}:{}", + harness.path, + launcher_dir = launcher_dir.display() + ); + + let output = harness.run_with_env( + &["doctor", "--fix"], + &[("PATH", &path), ("SHELL", "/bin/bash")], + ); + + assert!(output.status.success(), "{}", stderr_string(&output)); + let launcher = fs::read_to_string(launcher_dir.join("mb")).expect("launcher"); + assert!(launcher.contains("Memory Bank managed launcher")); +} + +#[test] +fn mb_service_install_writes_the_managed_service_definition() { + let harness = MbHarness::new(); + harness.seed_service_binary_placeholders(); + + let autostart = harness.run(&["config", "set", "service.autostart", "true"]); + assert!(autostart.status.success(), "{}", stderr_string(&autostart)); + + let output = harness.run(&["service", "install"]); + assert!(output.status.success(), "{}", stderr_string(&output)); + assert_contains_all( + &stdout_string(&output), + &[ + "Installing the managed service definition...", + "Success: Installed the managed service definition.", + "Autostart: yes", + "Log file:", + ], + ); + + #[cfg(target_os = "macos")] + let service_path = harness + .paths + .home_dir + .join("Library/LaunchAgents/com.memory-bank.mb.plist"); + #[cfg(target_os = "linux")] + let service_path = harness + .paths + .home_dir + .join(".config/systemd/user/memory-bank.service"); + + let rendered = fs::read_to_string(service_path).expect("service definition"); + assert!(rendered.contains("internal")); + assert!(rendered.contains("run-server")); + assert!(rendered.contains("server.log")); + #[cfg(target_os = "macos")] + assert!(rendered.contains("")); +} + +#[test] +fn mb_service_install_does_not_poll_for_activation() { + let harness = MbHarness::new(); + harness.seed_service_binary_placeholders(); + + let output = harness.run(&["service", "install"]); + assert!(output.status.success(), "{}", stderr_string(&output)); + + let log = harness.read_shim_log(); + #[cfg(target_os = "macos")] + assert!(count_occurrences(&log, "launchctl print") <= 2, "{log}"); + #[cfg(target_os = "linux")] + assert!( + count_occurrences( + &log, + "systemctl --user is-active --quiet memory-bank.service" + ) <= 2, + "{log}" + ); +} + +#[test] +fn mb_service_status_shows_runtime_summary_when_health_is_available() { + let harness = MbHarness::new(); + harness.seed_service_binary_placeholders(); + let healthz = HealthzServer::new("default", "anthropic", "fast-embed", "test"); + + let set_port = harness.run(&["config", "set", "service.port", &healthz.port().to_string()]); + assert!(set_port.status.success(), "{}", stderr_string(&set_port)); + let install = harness.run(&["service", "install"]); + assert!(install.status.success(), "{}", stderr_string(&install)); + + let status = harness.run_with_env( + &["service", "status"], + &[ + #[cfg(target_os = "macos")] + ("MB_TEST_LAUNCHCTL_PRINT_EXIT", "0"), + #[cfg(target_os = "macos")] + ("MB_TEST_LAUNCHCTL_PID", "4242"), + #[cfg(target_os = "linux")] + ("MB_TEST_SYSTEMCTL_IS_ACTIVE_EXIT", "0"), + #[cfg(target_os = "linux")] + ("MB_TEST_SYSTEMCTL_MAINPID", "4242"), + ], + ); + assert!(status.status.success(), "{}", stderr_string(&status)); + assert_contains_all( + &stdout_string(&status), + &[ + "Memory Bank service", + "Manager:", + "Installed: yes", + "Active: yes", + "URL:", + "Log file:", + "PID: 4242", + "Health: yes", + "Namespace: default", + &format!("Port: {}", healthz.port()), + "Provider: anthropic", + "Encoder: fast-embed", + "Version: test", + ], + ); +} + +#[test] +fn mb_status_reports_reindexing_when_service_is_active_but_not_healthy() { + let harness = MbHarness::new(); + harness.seed_service_binary_placeholders(); + + let install = harness.run(&["service", "install"]); + assert!(install.status.success(), "{}", stderr_string(&install)); + harness.write_startup_state(&memory_bank_app::ServerStartupState { + pid: 4242, + namespace: "default".to_string(), + phase: memory_bank_app::ServerStartupPhase::Reindexing, + memory_count: Some(12), + }); + + let status = harness.run_with_env( + &["status"], + &[ + #[cfg(target_os = "macos")] + ("MB_TEST_LAUNCHCTL_PRINT_EXIT", "0"), + #[cfg(target_os = "macos")] + ("MB_TEST_LAUNCHCTL_PID", "4242"), + #[cfg(target_os = "linux")] + ("MB_TEST_SYSTEMCTL_IS_ACTIVE_EXIT", "0"), + #[cfg(target_os = "linux")] + ("MB_TEST_SYSTEMCTL_MAINPID", "4242"), + ], + ); + assert!(status.status.success(), "{}", stderr_string(&status)); + assert_contains_all( + &stdout_string(&status), + &[ + "Memory Bank", + "Active: yes", + "Health: unavailable", + "Startup: reindexing 12 stored memories", + "rebuilding the vector index and re-encoding 12 stored memories", + ], + ); +} + +#[test] +fn mb_service_commands_report_progress_and_outcomes() { + let harness = MbHarness::new(); + harness.seed_service_binary_placeholders(); + + let start = harness.run_with_env( + &["service", "start"], + &[ + #[cfg(target_os = "macos")] + ("MB_TEST_LAUNCHCTL_PRINT_EXIT", "1"), + #[cfg(target_os = "linux")] + ("MB_TEST_SYSTEMCTL_IS_ACTIVE_EXIT", "1"), + ], + ); + assert!(start.status.success(), "{}", stderr_string(&start)); + assert_contains_all( + &stdout_string(&start), + &[ + "Starting Memory Bank service...", + "Warning: Sent the service request, but the managed service does not appear active yet.", + "mb service status", + ], + ); + + #[cfg(target_os = "macos")] + { + let log = harness.read_shim_log(); + assert!(log.contains("id -u")); + assert!(log.contains("launchctl print")); + assert!(log.contains("launchctl bootstrap")); + assert!(log.contains("launchctl kickstart -k")); + } + + #[cfg(target_os = "linux")] + { + let log = harness.read_shim_log(); + assert!(log.contains("systemctl --user start memory-bank.service")); + } + + let restart = harness.run_with_env( + &["service", "restart"], + &[ + #[cfg(target_os = "macos")] + ("MB_TEST_LAUNCHCTL_PRINT_EXIT", "1"), + #[cfg(target_os = "linux")] + ("MB_TEST_SYSTEMCTL_IS_ACTIVE_EXIT", "1"), + ], + ); + assert!(restart.status.success(), "{}", stderr_string(&restart)); + assert_contains_all( + &stdout_string(&restart), + &[ + "Restarting Memory Bank service...", + "Warning: Sent the service request, but the managed service does not appear active yet.", + ], + ); + + #[cfg(target_os = "macos")] + { + let log = harness.read_shim_log(); + assert!(log.contains("launchctl bootstrap")); + } + + #[cfg(target_os = "linux")] + { + let log = harness.read_shim_log(); + assert!(log.contains("systemctl --user start memory-bank.service")); + } + + let stop = harness.run(&["service", "stop"]); + assert!(stop.status.success(), "{}", stderr_string(&stop)); + assert_contains_all( + &stdout_string(&stop), + &[ + "Stopping Memory Bank service...", + "Warning: The managed service is already stopped.", + ], + ); + + #[cfg(target_os = "macos")] + assert!(!harness.read_shim_log().contains("launchctl bootout")); + #[cfg(target_os = "linux")] + assert!( + !harness + .read_shim_log() + .contains("systemctl --user stop memory-bank.service") + ); +} + +#[test] +fn mb_service_stop_waits_for_inactive_transition() { + let harness = MbHarness::new(); + harness.seed_service_binary_placeholders(); + + let install = harness.run(&["service", "install"]); + assert!(install.status.success(), "{}", stderr_string(&install)); + fs::write(&harness.shim_log, "").expect("clear shim log"); + + let stop = harness.run_with_env( + &["service", "stop"], + &[ + #[cfg(target_os = "macos")] + ("MB_TEST_LAUNCHCTL_PRINT_SEQUENCE", "0,1"), + #[cfg(target_os = "linux")] + ("MB_TEST_SYSTEMCTL_IS_ACTIVE_SEQUENCE", "0,1"), + ], + ); + assert!(stop.status.success(), "{}", stderr_string(&stop)); + let output = stdout_string(&stop); + assert_contains_all( + &output, + &[ + "Stopping Memory Bank service...", + "Success: Stopped Memory Bank service.", + "Installed: yes", + "Active: no", + ], + ); + assert!( + !output.contains("still appears active"), + "unexpected stop warning:\n{output}" + ); + + #[cfg(target_os = "macos")] + assert!(harness.read_shim_log().contains("launchctl bootout")); + #[cfg(target_os = "linux")] + assert!( + harness + .read_shim_log() + .contains("systemctl --user stop memory-bank.service") + ); +} + +#[test] +fn mb_service_start_when_already_active_avoids_transition_poll_loops() { + let harness = MbHarness::new(); + harness.seed_service_binary_placeholders(); + let healthz = HealthzServer::new("default", "anthropic", "fast-embed", "test"); + + let set_port = harness.run(&["config", "set", "service.port", &healthz.port().to_string()]); + assert!(set_port.status.success(), "{}", stderr_string(&set_port)); + let install = harness.run(&["service", "install"]); + assert!(install.status.success(), "{}", stderr_string(&install)); + fs::write(&harness.shim_log, "").expect("clear shim log"); + + let output = harness.run_with_env( + &["service", "start"], + &[ + #[cfg(target_os = "macos")] + ("MB_TEST_LAUNCHCTL_PRINT_EXIT", "0"), + #[cfg(target_os = "macos")] + ("MB_TEST_LAUNCHCTL_PID", "4242"), + #[cfg(target_os = "linux")] + ("MB_TEST_SYSTEMCTL_IS_ACTIVE_EXIT", "0"), + #[cfg(target_os = "linux")] + ("MB_TEST_SYSTEMCTL_MAINPID", "4242"), + ], + ); + assert!(output.status.success(), "{}", stderr_string(&output)); + assert_contains_all( + &stdout_string(&output), + &[ + "Starting Memory Bank service...", + "Success: Memory Bank service was already active and is still running.", + "Health: yes", + ], + ); + + let log = harness.read_shim_log(); + #[cfg(target_os = "macos")] + assert!(count_occurrences(&log, "launchctl print") <= 4, "{log}"); + #[cfg(target_os = "linux")] + assert!( + count_occurrences( + &log, + "systemctl --user is-active --quiet memory-bank.service" + ) <= 4, + "{log}" + ); +} + +#[test] +fn mb_service_restart_waits_for_process_rollover() { + let harness = MbHarness::new(); + harness.seed_service_binary_placeholders(); + let healthz = HealthzServer::new("default", "anthropic", "fast-embed", "test"); + + let set_port = harness.run(&["config", "set", "service.port", &healthz.port().to_string()]); + assert!(set_port.status.success(), "{}", stderr_string(&set_port)); + let install = harness.run(&["service", "install"]); + assert!(install.status.success(), "{}", stderr_string(&install)); + fs::write(&harness.shim_log, "").expect("clear shim log"); + + let restart = harness.run_with_env( + &["service", "restart"], + &[ + #[cfg(target_os = "macos")] + ("MB_TEST_LAUNCHCTL_PRINT_SEQUENCE", "0,0,0,0"), + #[cfg(target_os = "macos")] + ("MB_TEST_LAUNCHCTL_PID_SEQUENCE", "4242,4242,9001,9001"), + #[cfg(target_os = "linux")] + ("MB_TEST_SYSTEMCTL_IS_ACTIVE_SEQUENCE", "0,0,0"), + #[cfg(target_os = "linux")] + ("MB_TEST_SYSTEMCTL_MAINPID_SEQUENCE", "4242,9001,9001"), + ], + ); + assert!(restart.status.success(), "{}", stderr_string(&restart)); + assert_contains_all( + &stdout_string(&restart), + &[ + "Restarting Memory Bank service...", + "Success: Restarted Memory Bank service.", + "Health: yes", + ], + ); + + let log = harness.read_shim_log(); + #[cfg(target_os = "macos")] + assert!( + count_occurrences(&log, "launchctl print") >= 3, + "expected restart rollover polling, log was:\n{log}" + ); + #[cfg(target_os = "linux")] + assert!( + count_occurrences( + &log, + "systemctl --user show memory-bank.service --property MainPID --value" + ) >= 2, + "expected pid recheck, log was:\n{log}" + ); +} diff --git a/memory-bank-server/src/actor.rs b/memory-bank-server/src/actor.rs index 80b417a..8dd5608 100644 --- a/memory-bank-server/src/actor.rs +++ b/memory-bank-server/src/actor.rs @@ -252,7 +252,9 @@ trait MemoryLlmClient { ) -> Result; } -trait MemoryEncoderClient { +pub(crate) trait MemoryEncoderClient { + async fn encode(&self, texts: Vec) -> Result>, EncoderError>; + async fn encode_memory(&self, payload: &EmbeddingInput<'_>) -> Result, EncoderError>; async fn encode_memories( @@ -285,6 +287,10 @@ impl MemoryLlmClient for LlmClient { } impl MemoryEncoderClient for EncoderClient { + async fn encode(&self, texts: Vec) -> Result>, EncoderError> { + self.encode(texts).await + } + async fn encode_memory(&self, payload: &EmbeddingInput<'_>) -> Result, EncoderError> { self.encode_memory(payload).await } @@ -312,6 +318,15 @@ impl From for MemoryNote { } } +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Debug)] +pub(crate) struct RetrievalOutcome { + pub notes: Vec, + pub knn_ids: Vec, + pub knn_matches: usize, + pub expanded_matches: usize, +} + /// Timeout for individual LLM API calls (memory analysis and graph evolution). const LLM_TIMEOUT: Duration = Duration::from_secs(60); @@ -429,65 +444,80 @@ impl MemoryActor { } async fn retrieve(&self, query: String) -> Result, McpError> { - debug!("Encoding retrieve_memory query"); - - // 1. Encode the query text - let mut embeddings = self - .encoder - .encode(vec![query]) + retrieve_with_encoder(&self.db, &self.encoder, self.nearest_neighbor_count, &query) .await - .map_err(|e| McpError::internal_error(format!("Encoder error: {}", e), None))?; + .map(|outcome| outcome.notes) + } +} - let embedding = take_single_embedding(std::mem::take(&mut embeddings), "query encoding") - .map_err(|e| McpError::internal_error(format!("Encoder error: {}", e), None))?; +pub(crate) async fn retrieve_with_encoder( + db: &MemoryDb, + encoder: &E, + nearest_neighbor_count: i32, + query: &str, +) -> Result +where + E: MemoryEncoderClient + ?Sized, +{ + debug!("Encoding retrieve_memory query"); - let embedding_bytes = embedding_to_bytes(&embedding); + // 1. Encode the query text + let mut embeddings = encoder + .encode(vec![query.to_string()]) + .await + .map_err(|e| McpError::internal_error(format!("Encoder error: {}", e), None))?; - // 2. KNN search for top K matches - let top_ids = self - .db - .find_nearest_neighbors(&embedding_bytes, self.nearest_neighbor_count) - .await - .map_err(|e| McpError::internal_error(format!("DB query error: {}", e), None))?; + let embedding = take_single_embedding(std::mem::take(&mut embeddings), "query encoding") + .map_err(|e| McpError::internal_error(format!("Encoder error: {}", e), None))?; - if top_ids.is_empty() { - info!( - knn_matches = 0, - returned = 0, - "Memory retrieval produced no matches" - ); - return Ok(vec![]); - } + let embedding_bytes = embedding_to_bytes(&embedding); - // 3. Expand to 1-hop subgraph via link traversal - let knn_count = top_ids.len(); - let links = self - .db - .get_links_for_ids(&top_ids) - .await - .unwrap_or_default(); - let ordered_ids = ordered_retrieval_ids(&top_ids, &links); - let expanded_count = ordered_ids.len(); + // 2. KNN search for top K matches + let top_ids = db + .find_nearest_neighbors(&embedding_bytes, nearest_neighbor_count) + .await + .map_err(|e| McpError::internal_error(format!("DB query error: {}", e), None))?; - // 4. Fetch and return the semantic content - self.db - .get_memories(&ordered_ids) - .await - .map(|mut rows| { - sort_retrieval_rows(&mut rows, &ordered_ids); - let notes: Vec = rows.into_iter().map(MemoryNote::from).collect(); - info!( - knn_matches = knn_count, - expanded_matches = expanded_count, - returned = notes.len(), - "Memory retrieval completed", - ); - notes - }) - .map_err(|e| { - McpError::internal_error(format!("DB error fetching memories: {}", e), None) - }) + if top_ids.is_empty() { + info!( + knn_matches = 0, + returned = 0, + "Memory retrieval produced no matches" + ); + return Ok(RetrievalOutcome { + notes: Vec::new(), + knn_ids: Vec::new(), + knn_matches: 0, + expanded_matches: 0, + }); } + + // 3. Expand to 1-hop subgraph via link traversal + let knn_count = top_ids.len(); + let links = db.get_links_for_ids(&top_ids).await.unwrap_or_default(); + let ordered_ids = ordered_retrieval_ids(&top_ids, &links); + let expanded_count = ordered_ids.len(); + + // 4. Fetch and return the semantic content + db.get_memories(&ordered_ids) + .await + .map(|mut rows| { + sort_retrieval_rows(&mut rows, &ordered_ids); + let notes: Vec = rows.into_iter().map(MemoryNote::from).collect(); + info!( + knn_matches = knn_count, + expanded_matches = expanded_count, + returned = notes.len(), + "Memory retrieval completed", + ); + RetrievalOutcome { + notes, + knn_ids: top_ids, + knn_matches: knn_count, + expanded_matches: expanded_count, + } + }) + .map_err(|e| McpError::internal_error(format!("DB error fetching memories: {}", e), None)) } fn estimate_token_count(text: &str) -> usize { @@ -769,6 +799,28 @@ where persist_prepared_turn(db, turn_id, prepared_turn).await } +#[cfg(test)] +pub(crate) async fn process_turn_for_real_clients( + db: &MemoryDb, + llm: &LlmClient, + encoder: &EncoderClient, + nearest_neighbor_count: i32, + turn_id: i64, + window: ProjectedConversationWindow, + timestamp: DateTime, +) -> Result<(), ProcessTurnError> { + process_turn_with_clients( + db, + llm, + encoder, + nearest_neighbor_count, + turn_id, + window, + timestamp, + ) + .await +} + async fn prepare_neighbor_writes( encoder: &E, neighbor_updates: &[NeighborUpdate], @@ -964,13 +1016,23 @@ mod tests { #[derive(Clone, Default)] struct FakeEncoder { + encode_results: Arc>, EncoderError>>>>, encode_memory_results: Arc, EncoderError>>>>, encode_memories_results: Arc>, EncoderError>>>>, } impl FakeEncoder { + fn from_query_results(results: Vec>, EncoderError>>) -> Self { + Self { + encode_results: Arc::new(Mutex::new(results)), + encode_memory_results: Arc::new(Mutex::new(Vec::new())), + encode_memories_results: Arc::new(Mutex::new(Vec::new())), + } + } + fn from_memory_results(results: Vec, EncoderError>>) -> Self { Self { + encode_results: Arc::new(Mutex::new(Vec::new())), encode_memory_results: Arc::new(Mutex::new(results)), encode_memories_results: Arc::new(Mutex::new(Vec::new())), } @@ -978,6 +1040,10 @@ mod tests { } impl MemoryEncoderClient for FakeEncoder { + async fn encode(&self, _texts: Vec) -> Result>, EncoderError> { + self.encode_results.lock().expect("lock").remove(0) + } + async fn encode_memory( &self, _payload: &EmbeddingInput<'_>, @@ -1179,6 +1245,66 @@ mod tests { ); } + #[tokio::test] + async fn retrieve_with_encoder_expands_linked_neighbors_after_knn_hits() { + let db = open_memory_db().await; + let anchor_id = insert_memory_with_embedding( + &db, + "Tokio mpsc backpressure keeps bounded queues stable.", + "anchor", + &["tokio", "mpsc", "backpressure"], + &["rust", "async", "queue"], + &[1.0, 0.0], + ) + .await; + let secondary_id = insert_memory_with_embedding( + &db, + "Bounded async queue pressure can stall producers until receivers catch up.", + "secondary", + &["bounded", "queue", "pressure"], + &["rust", "async", "queue"], + &[0.95, 0.05], + ) + .await; + let linked_id = insert_memory_with_embedding( + &db, + "Channel sizing guidance explains when to increase buffer capacity.", + "linked", + &["channel", "buffer", "capacity"], + &["rust", "async", "tuning"], + &[0.1, 0.9], + ) + .await; + + let mut tx = db.begin().await.expect("begin"); + db.insert_bidirectional_links(&mut tx, anchor_id, linked_id) + .await + .expect("insert link"); + tx.commit().await.expect("commit"); + + let encoder = FakeEncoder::from_query_results(vec![Ok(vec![vec![1.0, 0.0]])]); + let outcome = retrieve_with_encoder(&db, &encoder, 2, "bounded rust queue backpressure") + .await + .expect("retrieve"); + + assert_eq!(outcome.knn_matches, 2); + assert_eq!(outcome.expanded_matches, 3); + assert_eq!( + outcome + .notes + .iter() + .map(|note| note.content.as_str()) + .collect::>(), + vec![ + "Tokio mpsc backpressure keeps bounded queues stable.", + "Bounded async queue pressure can stall producers until receivers catch up.", + "Channel sizing guidance explains when to increase buffer capacity.", + ] + ); + + let _ = secondary_id; + } + #[tokio::test] async fn refined_tag_reencoding_failure_aborts_before_persisting() { let db = open_memory_db().await; diff --git a/memory-bank-server/src/config.rs b/memory-bank-server/src/config.rs index 10a1fbd..d37827a 100644 --- a/memory-bank-server/src/config.rs +++ b/memory-bank-server/src/config.rs @@ -13,6 +13,7 @@ pub struct Dirs { pub data: PathBuf, pub db: PathBuf, pub models: PathBuf, + pub startup_state: PathBuf, } impl Dirs { @@ -23,6 +24,7 @@ impl Dirs { Ok(Self { db: data.join("memory.db"), models, + startup_state: paths.server_startup_state_path(namespace), data, }) } @@ -89,7 +91,11 @@ impl LlmProviderConfig { ), }), LlmProviderType::Ollama => Ok(Self::Ollama { - url: env_setting_or_default("MEMORY_BANK_OLLAMA_URL", None, DEFAULT_OLLAMA_URL), + url: env_setting_or_default( + "MEMORY_BANK_OLLAMA_URL", + settings.and_then(|s| s.ollama_url.as_deref()), + DEFAULT_OLLAMA_URL, + ), model: env_setting_or_default( "MEMORY_BANK_OLLAMA_MODEL", settings.and_then(|s| s.llm_model.as_deref()), @@ -388,7 +394,7 @@ mod tests { unsafe { env::set_var("MEMORY_BANK_OLLAMA_URL", "http://127.0.0.1:11434"); - env::set_var("MEMORY_BANK_OLLAMA_MODEL", "qwen3:8b"); + env::set_var("MEMORY_BANK_OLLAMA_MODEL", "qwen3"); } let config = @@ -397,7 +403,7 @@ mod tests { assert!(matches!( config, LlmProviderConfig::Ollama { url, model } - if url == "http://127.0.0.1:11434" && model == "qwen3:8b" + if url == "http://127.0.0.1:11434" && model == "qwen3" )); } diff --git a/memory-bank-server/src/db.rs b/memory-bank-server/src/db.rs index fbb1ee1..0c36dbd 100644 --- a/memory-bank-server/src/db.rs +++ b/memory-bank-server/src/db.rs @@ -8,6 +8,7 @@ use tracing::{debug, error, info, warn}; use crate::encoder::{EmbeddingInput, EncoderClient, embedding_to_bytes, validate_embedding_count}; use crate::error::AppError; +use crate::startup_state::StartupStateTracker; // --- Public helpers and row types --- @@ -48,12 +49,20 @@ impl MemoryDb { llm_model_id: &str, encoder_model_id: &str, encoder: &EncoderClient, + startup_state: Option<&StartupStateTracker>, ) -> Result { register_sqlite_vec(); let pool = create_pool(db_path).await?; create_schema(&pool).await?; - ensure_vec_table(&pool, llm_model_id, encoder_model_id, encoder).await?; + ensure_vec_table( + &pool, + llm_model_id, + encoder_model_id, + encoder, + startup_state, + ) + .await?; info!( db_path = %db_path.display(), @@ -320,12 +329,20 @@ async fn ensure_vec_table( llm_model_id: &str, encoder_model_id: &str, encoder: &EncoderClient, + startup_state: Option<&StartupStateTracker>, ) -> Result<(), AppError> { let plan = VecTablePlan::build(pool, llm_model_id, encoder_model_id, encoder).await?; match plan.mode { VecTableMode::Rebuild => { - rebuild_vec_table(pool, plan.vec_table_exists, plan.dimension, encoder).await?; + rebuild_vec_table( + pool, + plan.vec_table_exists, + plan.dimension, + encoder, + startup_state, + ) + .await?; } VecTableMode::EnsureExists => { create_vec_table(pool, plan.dimension).await?; @@ -447,6 +464,7 @@ async fn rebuild_vec_table( vec_table_exists: bool, dimension: usize, encoder: &EncoderClient, + startup_state: Option<&StartupStateTracker>, ) -> Result<(), AppError> { if vec_table_exists { info!(dimension, "Dropping stale vector index before rebuild"); @@ -454,7 +472,7 @@ async fn rebuild_vec_table( } create_vec_table(pool, dimension).await?; - migrate_embeddings(pool, encoder).await?; + migrate_embeddings(pool, encoder, startup_state).await?; Ok(()) } @@ -472,13 +490,21 @@ async fn create_vec_table(pool: &SqlitePool, dimension: usize) -> Result<(), App Ok(()) } -async fn migrate_embeddings(pool: &SqlitePool, encoder: &EncoderClient) -> Result<(), AppError> { +async fn migrate_embeddings( + pool: &SqlitePool, + encoder: &EncoderClient, + startup_state: Option<&StartupStateTracker>, +) -> Result<(), AppError> { let memories = load_memories_for_migration(pool).await?; if memories.is_empty() { info!("Vector index ready with no existing memories to migrate"); return Ok(()); } + let _reindex_guard = startup_state + .map(|state| state.begin_reindex(memories.len())) + .transpose()?; + info!( memory_count = memories.len(), chunk_size = MIGRATION_CHUNK_SIZE, @@ -797,7 +823,7 @@ mod tests { upsert_model_config(&pool, "Anthropic::claude-sonnet-4-6", "FastEmbed::default") .await .expect("insert initial config"); - upsert_model_config(&pool, "OpenAi::gpt-4o-mini", "FastEmbed::default") + upsert_model_config(&pool, "OpenAi::gpt-5-mini", "FastEmbed::default") .await .expect("refresh llm config"); @@ -806,7 +832,7 @@ mod tests { .expect("load updated model config") .expect("stored row"); - assert_eq!(stored.llm_model, "OpenAi::gpt-4o-mini"); + assert_eq!(stored.llm_model, "OpenAi::gpt-5-mini"); assert_eq!(stored.encoder_model, "FastEmbed::default"); } } diff --git a/memory-bank-server/src/lib.rs b/memory-bank-server/src/lib.rs index 747e827..4128083 100644 --- a/memory-bank-server/src/lib.rs +++ b/memory-bank-server/src/lib.rs @@ -6,14 +6,20 @@ pub mod error; mod http_server; mod ingest; mod llm; +#[cfg(test)] +mod llm_eval; mod logging; mod mcp_server; mod memory_window; +#[cfg(test)] +mod retrieval_eval; +mod startup_state; use crate::actor::MemoryActor; use crate::config::ServeConfig; use crate::http_server::{HealthResponse, HttpServer}; use crate::ingest::IngestService; +use crate::startup_state::StartupStateTracker; use tracing::info; pub async fn run(config: ServeConfig) -> Result<(), error::AppError> { @@ -60,8 +66,15 @@ pub async fn run(config: ServeConfig) -> Result<(), error::AppError> { encoder_model = %encoder.model_id, "Opening memory database", ); - let db = - db::MemoryDb::open(&dirs.db, &llm.model_id, &encoder.model_id, &encoder.client).await?; + let startup_state = StartupStateTracker::new(dirs.startup_state.clone(), namespace.to_string()); + let db = db::MemoryDb::open( + &dirs.db, + &llm.model_id, + &encoder.model_id, + &encoder.client, + Some(&startup_state), + ) + .await?; let llm_provider_name = llm.provider_name().to_string(); let encoder_provider_name = encoder.provider_name().to_string(); diff --git a/memory-bank-server/src/llm.rs b/memory-bank-server/src/llm.rs index 06cf78a..35b9404 100644 --- a/memory-bank-server/src/llm.rs +++ b/memory-bank-server/src/llm.rs @@ -425,7 +425,11 @@ fn verify_ollama_model(base_url: &str, model: &str) -> Result<(), AppError> { )) })?; - if tags.models.iter().any(|candidate| candidate.name == model) { + if tags + .models + .iter() + .any(|candidate| ollama_model_matches(model, &candidate.name)) + { return Ok(()); } @@ -446,6 +450,11 @@ fn verify_ollama_model(base_url: &str, model: &str) -> Result<(), AppError> { ))) } +fn ollama_model_matches(requested: &str, available: &str) -> bool { + available == requested + || (!requested.contains(':') && available == format!("{requested}:latest")) +} + #[cfg(test)] mod tests { use super::{ @@ -463,7 +472,7 @@ mod tests { #[test] fn anthropic_prompt_caching_is_enabled() { let client = Client::new("test-key").expect("anthropic client"); - let model = anthropic_completion_model(&client, "claude-sonnet-4"); + let model = anthropic_completion_model(&client, "claude-sonnet-4-6"); assert!(model.prompt_caching); } @@ -471,8 +480,8 @@ mod tests { #[tokio::test] async fn structured_agents_use_zero_temperature() { let client = Client::new("test-key").expect("anthropic client"); - let model = anthropic_completion_model(&client, "claude-sonnet-4"); - let llm = super::build_rig_structured_llm(model, "Anthropic::claude-sonnet-4"); + let model = anthropic_completion_model(&client, "claude-sonnet-4-6"); + let llm = super::build_rig_structured_llm(model, "Anthropic::claude-sonnet-4-6"); assert_eq!(llm.analysis_agent.temperature, Some(0.0)); assert_eq!(llm.evolve_agent.temperature, Some(0.0)); @@ -543,7 +552,7 @@ mod tests { assert!(matches!( llm_client_from_config(LlmProviderConfig::Gemini { api_key: "test-key".to_string(), - model: "gemini-2.5-flash".to_string(), + model: "gemini-3-flash-preview".to_string(), }) .expect("gemini client"), LlmClient::Gemini(_) @@ -561,7 +570,7 @@ mod tests { assert!(matches!( llm_client_from_config(LlmProviderConfig::OpenAi { api_key: "test-key".to_string(), - model: "gpt-4o-mini".to_string(), + model: "gpt-5-mini".to_string(), }) .expect("openai client"), LlmClient::OpenAi(_) @@ -572,12 +581,12 @@ mod tests { async fn initialize_preserves_model_ids_for_remote_providers() { let InitializedLlm { client, model_id } = super::initialize(LlmProviderConfig::OpenAi { api_key: "test-key".to_string(), - model: "gpt-4o-mini".to_string(), + model: "gpt-5-mini".to_string(), }) .expect("initialize openai"); assert!(matches!(client, LlmClient::OpenAi(_))); - assert_eq!(model_id, "OpenAi::gpt-4o-mini"); + assert_eq!(model_id, "OpenAi::gpt-5-mini"); } #[test] @@ -614,14 +623,14 @@ mod tests { 200, serde_json::to_string(&serde_json::json!({ "models": [ - { "name": "qwen3:4b" }, + { "name": "qwen3:latest" }, { "name": "llama3.2" } ] })) .expect("mock response"), ); - verify_ollama_model(&mock.base_url, "qwen3:4b").expect("verified model"); + verify_ollama_model(&mock.base_url, "qwen3").expect("verified model"); mock.join(); } @@ -636,10 +645,10 @@ mod tests { ); let error = - verify_ollama_model(&mock.base_url, "qwen3:4b").expect_err("missing model should fail"); + verify_ollama_model(&mock.base_url, "qwen3").expect_err("missing model should fail"); mock.join(); - assert!(error.to_string().contains("ollama pull qwen3:4b")); + assert!(error.to_string().contains("ollama pull qwen3")); assert!(error.to_string().contains("llama3.2")); } @@ -649,7 +658,7 @@ mod tests { let port = listener.local_addr().expect("local addr").port(); drop(listener); - let error = verify_ollama_model(&format!("http://127.0.0.1:{port}"), "qwen3:4b") + let error = verify_ollama_model(&format!("http://127.0.0.1:{port}"), "qwen3") .expect_err("unreachable daemon should fail"); assert!(error.to_string().contains("Unable to reach Ollama")); @@ -661,7 +670,7 @@ mod tests { 200, serde_json::to_string(&OllamaTagsResponse { models: vec![super::OllamaModel { - name: "qwen3:4b".to_string(), + name: "qwen3:latest".to_string(), }], }) .expect("mock response"), @@ -669,14 +678,14 @@ mod tests { let InitializedLlm { client, model_id } = super::initialize(LlmProviderConfig::Ollama { url: mock.base_url.clone(), - model: "qwen3:4b".to_string(), + model: "qwen3".to_string(), }) .expect("initialize ollama"); let base_url = mock.base_url.clone(); mock.join(); assert!(matches!(client, LlmClient::Ollama(_))); - assert_eq!(model_id, format!("Ollama::qwen3:4b@{}", base_url)); + assert_eq!(model_id, format!("Ollama::qwen3@{}", base_url)); } struct MockOllamaServer { diff --git a/memory-bank-server/src/llm_eval.rs b/memory-bank-server/src/llm_eval.rs new file mode 100644 index 0000000..38ecf4b --- /dev/null +++ b/memory-bank-server/src/llm_eval.rs @@ -0,0 +1,733 @@ +use crate::actor::process_turn_for_real_clients; +use crate::config::{EncoderProviderConfig, LlmProviderConfig, LlmProviderType}; +use crate::db::MemoryDb; +use crate::encoder::{self, EmbeddingInput, EncoderClient}; +use crate::llm::{self, LlmClient}; +use crate::memory_window::{MemoryProjection, MemoryStep, ProjectedConversationWindow}; +use crate::retrieval_eval::real_eval_lock; +use chrono::Utc; +use memory_bank_app::{ + AppPaths, AppSettings, DEFAULT_ANTHROPIC_MODEL, DEFAULT_FASTEMBED_MODEL, DEFAULT_GEMINI_MODEL, + DEFAULT_OLLAMA_MODEL, DEFAULT_OLLAMA_URL, DEFAULT_OPENAI_MODEL, +}; +use serde::Serialize; +use sqlx::Row; +use std::collections::HashMap; +use std::env; +use std::fmt; +use std::fs; +use std::path::PathBuf; + +const LLM_EVAL_ENV: &str = "MEMORY_BANK_LLM_EVALS"; +const LLM_EVAL_MODEL_ENV: &str = "MEMORY_BANK_LLM_EVAL_MODEL"; +const NOTE_TIMESTAMP: &str = "2026-03-30T00:00:00Z"; +const QUORUM_RUNS: usize = 3; +const QUORUM_REQUIRED: usize = 2; +const NEAREST_NEIGHBOR_COUNT: i32 = 3; + +#[derive(Debug, Clone)] +struct SeedNote { + slug: &'static str, + content: &'static str, + context: &'static str, + keywords: &'static [&'static str], + tags: &'static [&'static str], +} + +impl SeedNote { + fn keywords_as_strings(&self) -> Vec { + self.keywords + .iter() + .map(|value| (*value).to_string()) + .collect() + } + + fn tags_as_strings(&self) -> Vec { + self.tags.iter().map(|value| (*value).to_string()).collect() + } +} + +#[derive(Debug, Clone)] +struct FixtureExpectation { + required_links: Vec<&'static str>, + forbidden_links: Vec<&'static str>, +} + +#[derive(Debug, Clone)] +struct LlmFixture { + name: &'static str, + description: &'static str, + notes: Vec, + window: ProjectedConversationWindow, + expectation: FixtureExpectation, +} + +#[derive(Debug, Serialize)] +struct LlmEvalReport { + suite: &'static str, + provider_name: String, + encoder_model_id: String, + model_id: String, + nearest_neighbor_count: i32, + quorum_runs: usize, + quorum_required: usize, + fixtures: Vec, +} + +#[derive(Debug, Serialize)] +struct FixtureReport { + name: String, + description: String, + passed_runs: usize, + total_runs: usize, + runs: Vec, +} + +#[derive(Debug, Serialize)] +struct FixtureRunReport { + fixture_name: String, + run_index: usize, + model_id: String, + stored_memory_id: Option, + linked_target_slugs: Vec, + updated_neighbor_slugs: Vec, + keywords: Vec, + tags: Vec, + context_preview: String, + turn_status: Option, + passed: bool, + pass_reasons: Vec, + fail_reasons: Vec, +} + +struct RealHarness { + encoder_model_id: String, + encoder_client: EncoderClient, + llm_provider_name: String, + llm_model_id: String, + llm_client: LlmClient, +} + +fn evals_enabled(test_name: &str) -> bool { + if env::var(LLM_EVAL_ENV).ok().as_deref() == Some("1") { + true + } else { + eprintln!("skipping {test_name} because {LLM_EVAL_ENV}=1 is not set"); + false + } +} + +fn default_models_dir() -> PathBuf { + let paths = AppPaths::from_system().expect("resolve app paths"); + paths.ensure_base_dirs().expect("create app dirs"); + let models_dir = paths.models_dir(); + fs::create_dir_all(&models_dir).expect("create models dir"); + models_dir +} + +fn initialize_real_harness() -> RealHarness { + let models_dir = default_models_dir(); + let encoder_model = env::var("MEMORY_BANK_FASTEMBED_MODEL") + .unwrap_or_else(|_| DEFAULT_FASTEMBED_MODEL.to_string()); + let encoder = encoder::initialize( + EncoderProviderConfig::FastEmbed { + model: encoder_model, + }, + &models_dir, + ) + .expect("initialize real fastembed encoder"); + + let base_llm_config = + resolve_current_llm_provider_config().expect("resolve configured llm provider"); + let llm_config = match resolve_eval_model_override() { + Some(model) => base_llm_config.with_model_override(model), + None => base_llm_config, + }; + let llm_provider_name = llm_config.provider_name().to_string(); + let llm = llm::initialize(llm_config).expect("initialize real llm provider"); + + RealHarness { + encoder_model_id: encoder.model_id, + encoder_client: encoder.client, + llm_provider_name, + llm_model_id: llm.model_id, + llm_client: llm.client, + } +} + +fn resolve_current_llm_provider_config() -> Result { + let app_paths = AppPaths::from_system() + .map_err(|error| crate::error::AppError::Config(error.to_string()))?; + let settings = AppSettings::load(&app_paths) + .map_err(|error| crate::error::AppError::Config(error.to_string()))?; + let server_settings = settings.server.as_ref(); + let env_provider = env::var("MEMORY_BANK_LLM_PROVIDER").ok(); + let env_provider = parse_optional_value(env_provider.as_deref())?; + let settings_provider = + parse_optional_value(server_settings.and_then(|value| value.llm_provider.as_deref()))?; + let provider = env_provider + .or(settings_provider) + .unwrap_or(LlmProviderType::Anthropic); + + resolve_llm_provider_config(provider, server_settings) +} + +fn resolve_llm_provider_config( + provider: LlmProviderType, + settings: Option<&memory_bank_app::ServerSettings>, +) -> Result { + match provider { + LlmProviderType::Gemini => Ok(LlmProviderConfig::Gemini { + api_key: require_env("GEMINI_API_KEY")?, + model: env_setting_or_default( + "MEMORY_BANK_LLM_MODEL", + settings.and_then(|value| value.llm_model.as_deref()), + DEFAULT_GEMINI_MODEL, + ), + }), + LlmProviderType::Anthropic => Ok(LlmProviderConfig::Anthropic { + api_key: require_env("ANTHROPIC_API_KEY")?, + model: env_setting_or_default( + "MEMORY_BANK_LLM_MODEL", + settings.and_then(|value| value.llm_model.as_deref()), + DEFAULT_ANTHROPIC_MODEL, + ), + }), + LlmProviderType::OpenAi => Ok(LlmProviderConfig::OpenAi { + api_key: require_env("OPENAI_API_KEY")?, + model: env_setting_or_default( + "MEMORY_BANK_LLM_MODEL", + settings.and_then(|value| value.llm_model.as_deref()), + DEFAULT_OPENAI_MODEL, + ), + }), + LlmProviderType::Ollama => Ok(LlmProviderConfig::Ollama { + url: env_setting_or_default( + "MEMORY_BANK_OLLAMA_URL", + settings.and_then(|value| value.ollama_url.as_deref()), + DEFAULT_OLLAMA_URL, + ), + model: env_setting_or_default( + "MEMORY_BANK_OLLAMA_MODEL", + settings.and_then(|value| value.llm_model.as_deref()), + DEFAULT_OLLAMA_MODEL, + ), + }), + } +} + +fn parse_optional_value(value: Option<&str>) -> Result, crate::error::AppError> +where + T: std::str::FromStr, + T::Err: fmt::Display, +{ + match value { + Some(value) => value + .parse::() + .map(Some) + .map_err(|error| crate::error::AppError::Config(error.to_string())), + None => Ok(None), + } +} + +fn require_env(name: &str) -> Result { + env::var(name).map_err(|_| crate::error::AppError::Config(format!("{name} must be set"))) +} + +fn env_setting_or_default(name: &str, setting: Option<&str>, default: &str) -> String { + env::var(name) + .ok() + .or_else(|| setting.map(str::to_owned)) + .unwrap_or_else(|| default.to_string()) +} + +trait TestLlmConfigExt { + fn with_model_override(self, model: String) -> Self; +} + +impl TestLlmConfigExt for LlmProviderConfig { + fn with_model_override(self, model: String) -> Self { + match self { + LlmProviderConfig::Gemini { api_key, .. } => { + LlmProviderConfig::Gemini { api_key, model } + } + LlmProviderConfig::Anthropic { api_key, .. } => { + LlmProviderConfig::Anthropic { api_key, model } + } + LlmProviderConfig::OpenAi { api_key, .. } => { + LlmProviderConfig::OpenAi { api_key, model } + } + LlmProviderConfig::Ollama { url, .. } => LlmProviderConfig::Ollama { url, model }, + } + } +} + +fn resolve_eval_model_override() -> Option { + env::var(LLM_EVAL_MODEL_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +async fn seed_fixture_db( + notes: &[SeedNote], + encoder: &EncoderClient, +) -> (MemoryDb, HashMap, HashMap) { + let owned_keywords: Vec> = + notes.iter().map(SeedNote::keywords_as_strings).collect(); + let owned_tags: Vec> = notes.iter().map(SeedNote::tags_as_strings).collect(); + let payloads: Vec<_> = notes + .iter() + .zip(&owned_keywords) + .zip(&owned_tags) + .map(|((note, keywords), tags)| EmbeddingInput { + content: note.content, + keywords: keywords.as_slice(), + tags: tags.as_slice(), + context: note.context, + }) + .collect(); + let embeddings = encoder + .encode_memories(&payloads) + .await + .expect("encode seed notes"); + let dimension = embeddings + .first() + .map(std::vec::Vec::len) + .expect("seed embedding dimension"); + let db = MemoryDb::open_in_memory_for_tests(dimension) + .await + .expect("open in-memory eval db"); + create_processing_turn_table(&db).await; + + let mut slug_to_id = HashMap::with_capacity(notes.len()); + let mut id_to_slug = HashMap::with_capacity(notes.len()); + + let mut tx = db.begin().await.expect("begin note tx"); + for (note, embedding) in notes.iter().zip(embeddings) { + let id = db + .insert_memory( + &mut *tx, + note.content, + NOTE_TIMESTAMP, + note.context, + &serde_json::to_string(note.keywords).expect("keywords json"), + &serde_json::to_string(note.tags).expect("tags json"), + ) + .await + .expect("insert seeded memory"); + db.insert_embedding(&mut *tx, id, &encoder::embedding_to_bytes(&embedding)) + .await + .expect("insert seeded embedding"); + slug_to_id.insert(note.slug.to_string(), id); + id_to_slug.insert(id, note.slug.to_string()); + } + tx.commit().await.expect("commit note tx"); + + (db, slug_to_id, id_to_slug) +} + +async fn create_processing_turn_table(db: &MemoryDb) { + sqlx::query( + "CREATE TABLE ingest_turns ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL, + last_error TEXT, + next_attempt_at TEXT, + processing_started_at TEXT, + stored_at TEXT, + updated_at TEXT + );", + ) + .execute(db.pool_for_tests()) + .await + .expect("create ingest_turns"); +} + +async fn insert_processing_turn(db: &MemoryDb, turn_id: i64) { + sqlx::query( + "INSERT INTO ingest_turns ( + id, + status, + next_attempt_at, + processing_started_at, + updated_at + ) VALUES (?, 'processing', ?, ?, ?);", + ) + .bind(turn_id) + .bind(NOTE_TIMESTAMP) + .bind(NOTE_TIMESTAMP) + .bind(NOTE_TIMESTAMP) + .execute(db.pool_for_tests()) + .await + .expect("insert processing turn"); +} + +fn build_window( + user_message: &'static str, + assistant_reply: &'static str, +) -> ProjectedConversationWindow { + ProjectedConversationWindow { + previous_turns: Vec::new(), + current_turn: MemoryProjection { + user_message: user_message.to_string(), + assistant_reply: assistant_reply.to_string(), + steps: vec![MemoryStep::Thinking { + text: "Reviewing nearby memories before responding.".to_string(), + }], + }, + } +} + +fn build_fixtures() -> Vec { + vec![ + LlmFixture { + name: "links_obvious_related_memory", + description: "Links a paraphrased follow-up to the clearly related existing memory only.", + notes: vec![ + SeedNote { + slug: "tokio-related", + content: "Tokio mpsc backpressure means bounded senders have to wait when the receiver falls behind.", + context: "We discussed how bounded async queues in Rust intentionally slow producers under load.", + keywords: &["tokio", "mpsc", "backpressure"], + tags: &["rust", "async", "queue"], + }, + SeedNote { + slug: "http-distractor", + content: "Trace correlation headers keep logs grouped under the same HTTP request identifier.", + context: "This note is about observability headers, not queues or producer pressure.", + keywords: &["http", "trace", "correlation"], + tags: &["observability", "http", "middleware"], + }, + SeedNote { + slug: "cache-distractor", + content: "Single-flight cache refresh prevents a thundering herd on an expired hot key.", + context: "This note shares infra vocabulary but focuses on caching rather than async queues.", + keywords: &["cache", "single-flight", "stampede"], + tags: &["cache", "performance", "concurrency"], + }, + ], + window: build_window( + "What was that Rust queue concept where producers have to slow down if a bounded channel fills up?", + "That's the backpressure behavior in Tokio's bounded mpsc channels: senders wait until the receiver drains capacity.", + ), + expectation: FixtureExpectation { + required_links: vec!["tokio-related"], + forbidden_links: vec!["http-distractor", "cache-distractor"], + }, + }, + LlmFixture { + name: "avoids_bogus_links_for_shared_infra_words", + description: "Avoids linking notes that only share broad infrastructure vocabulary.", + notes: vec![ + SeedNote { + slug: "queue-neighbor", + content: "Queue pressure metrics help operators see when a worker backlog starts to rise.", + context: "This note is about generic queue monitoring, not HTTP correlation or request tracing.", + keywords: &["queue", "metrics", "backlog"], + tags: &["operations", "queueing", "monitoring"], + }, + SeedNote { + slug: "cache-neighbor", + content: "Cache invalidation runs after deployment so stale hot keys are refreshed gradually.", + context: "This note is about cache rollout hygiene, not per-request identifiers.", + keywords: &["cache", "deployment", "refresh"], + tags: &["cache", "release", "operations"], + }, + SeedNote { + slug: "storage-neighbor", + content: "Object storage retries should resume completed parts instead of starting the upload over.", + context: "This note concerns multipart uploads, not HTTP tracing or request metadata.", + keywords: &["storage", "multipart", "retry"], + tags: &["storage", "transfer", "resilience"], + }, + ], + window: build_window( + "We should keep the same request id on every server span so one API call is easy to follow in logs.", + "Yes. We should propagate a single trace correlation header through middleware and downstream handlers.", + ), + expectation: FixtureExpectation { + required_links: Vec::new(), + forbidden_links: vec!["queue-neighbor", "cache-neighbor", "storage-neighbor"], + }, + }, + LlmFixture { + name: "links_followup_decision_across_rewording", + description: "Links a reworded follow-up back to the prior decision memory only.", + notes: vec![ + SeedNote { + slug: "decision-note", + content: "The rollout decision was to keep a kill switch and automatic rollback guard during the staged release.", + context: "The team chose a safety-first staged rollout with a fast stop option if user-facing health degrades.", + keywords: &["rollout", "kill-switch", "rollback"], + tags: &["release", "safety", "operations"], + }, + SeedNote { + slug: "schema-distractor", + content: "Schema changes should stay additive so older API clients keep working.", + context: "This note is about compatibility policy, not rollout decisions or feature flags.", + keywords: &["schema", "compatibility", "api"], + tags: &["api", "schema", "compatibility"], + }, + SeedNote { + slug: "token-distractor", + content: "Refresh token rotation should detect replay of a stale credential.", + context: "This note is about credential security, not staged rollout safety decisions.", + keywords: &["oauth", "refresh-token", "rotation"], + tags: &["security", "authentication", "token-management"], + }, + ], + window: build_window( + "What did we agree to do for that risky feature launch if health drops after the first partial release?", + "We agreed to keep the staged release guarded by a fast kill switch and rollback path instead of pushing straight to everyone.", + ), + expectation: FixtureExpectation { + required_links: vec!["decision-note"], + forbidden_links: vec!["schema-distractor", "token-distractor"], + }, + }, + ] +} + +async fn run_fixture_once( + fixture: &LlmFixture, + run_index: usize, + harness: &RealHarness, +) -> FixtureRunReport { + let (db, _slug_to_id, id_to_slug) = + seed_fixture_db(&fixture.notes, &harness.encoder_client).await; + let turn_id = 10_000 + run_index as i64; + insert_processing_turn(&db, turn_id).await; + + let mut pass_reasons = Vec::new(); + let mut fail_reasons = Vec::new(); + + let result = process_turn_for_real_clients( + &db, + &harness.llm_client, + &harness.encoder_client, + NEAREST_NEIGHBOR_COUNT, + turn_id, + fixture.window.clone(), + Utc::now(), + ) + .await; + + if let Err(error) = result { + fail_reasons.push(format!("processing failed: {error}")); + return FixtureRunReport { + fixture_name: fixture.name.to_string(), + run_index, + model_id: harness.llm_model_id.clone(), + stored_memory_id: None, + linked_target_slugs: Vec::new(), + updated_neighbor_slugs: Vec::new(), + keywords: Vec::new(), + tags: Vec::new(), + context_preview: String::new(), + turn_status: None, + passed: false, + pass_reasons, + fail_reasons, + }; + } + + let stored_memory_id = sqlx::query_scalar::<_, i64>("SELECT MAX(id) FROM memories") + .fetch_one(db.pool_for_tests()) + .await + .expect("load stored memory id"); + let memory_row = sqlx::query("SELECT context, keywords, tags FROM memories WHERE id = ?") + .bind(stored_memory_id) + .fetch_one(db.pool_for_tests()) + .await + .expect("load stored memory row"); + let context = memory_row.get::("context"); + let keywords_json = memory_row.get::("keywords"); + let tags_json = memory_row.get::("tags"); + let keywords: Vec = serde_json::from_str(&keywords_json).unwrap_or_default(); + let tags: Vec = serde_json::from_str(&tags_json).unwrap_or_default(); + + let linked_target_ids = sqlx::query_scalar::<_, i64>( + "SELECT target_id FROM memory_links WHERE source_id = ? ORDER BY target_id", + ) + .bind(stored_memory_id) + .fetch_all(db.pool_for_tests()) + .await + .expect("load linked target ids"); + let linked_target_slugs: Vec = linked_target_ids + .iter() + .map(|id| { + id_to_slug + .get(id) + .cloned() + .unwrap_or_else(|| format!("unknown-id:{id}")) + }) + .collect(); + + let updated_neighbor_slugs = + compute_updated_neighbor_slugs(&db, &fixture.notes, &id_to_slug).await; + let turn_status = + sqlx::query_scalar::<_, String>("SELECT status FROM ingest_turns WHERE id = ?") + .bind(turn_id) + .fetch_one(db.pool_for_tests()) + .await + .ok(); + + if turn_status.as_deref() == Some("stored") { + pass_reasons.push("ingest turn transitioned to stored".to_string()); + } else { + fail_reasons.push("ingest turn did not transition to stored".to_string()); + } + + if !context.trim().is_empty() { + pass_reasons.push("stored memory has non-empty context".to_string()); + } else { + fail_reasons.push("stored memory context was empty".to_string()); + } + + if !keywords.is_empty() { + pass_reasons.push("stored memory has at least one keyword".to_string()); + } else { + fail_reasons.push("stored memory had no keywords".to_string()); + } + + if !tags.is_empty() { + pass_reasons.push("stored memory has at least one tag".to_string()); + } else { + fail_reasons.push("stored memory had no tags".to_string()); + } + + for required in &fixture.expectation.required_links { + if linked_target_slugs.iter().any(|slug| slug == required) { + pass_reasons.push(format!("required link present: {required}")); + } else { + fail_reasons.push(format!("missing required link: {required}")); + } + } + + for forbidden in &fixture.expectation.forbidden_links { + if linked_target_slugs.iter().any(|slug| slug == forbidden) { + fail_reasons.push(format!("unexpected link present: {forbidden}")); + } else { + pass_reasons.push(format!("forbidden link absent: {forbidden}")); + } + } + + let context_preview = if context.chars().count() <= 180 { + context + } else { + let preview: String = context.chars().take(180).collect(); + format!("{preview}...") + }; + + FixtureRunReport { + fixture_name: fixture.name.to_string(), + run_index, + model_id: harness.llm_model_id.clone(), + stored_memory_id: Some(stored_memory_id), + linked_target_slugs, + updated_neighbor_slugs, + keywords, + tags, + context_preview, + turn_status, + passed: fail_reasons.is_empty(), + pass_reasons, + fail_reasons, + } +} + +async fn compute_updated_neighbor_slugs( + db: &MemoryDb, + notes: &[SeedNote], + id_to_slug: &HashMap, +) -> Vec { + let mut updated = Vec::new(); + for note in notes { + let row = sqlx::query("SELECT context, keywords, tags FROM memories WHERE content = ?") + .bind(note.content) + .fetch_one(db.pool_for_tests()) + .await + .expect("load seeded note row"); + let context = row.get::("context"); + let keywords = row.get::("keywords"); + let tags = row.get::("tags"); + let original_keywords = serde_json::to_string(note.keywords).expect("orig keywords json"); + let original_tags = serde_json::to_string(note.tags).expect("orig tags json"); + if context != note.context || keywords != original_keywords || tags != original_tags { + let id = sqlx::query_scalar::<_, i64>("SELECT id FROM memories WHERE content = ?") + .bind(note.content) + .fetch_one(db.pool_for_tests()) + .await + .expect("load updated neighbor id"); + updated.push( + id_to_slug + .get(&id) + .cloned() + .unwrap_or_else(|| format!("unknown-id:{id}")), + ); + } + } + updated.sort(); + updated.dedup(); + updated +} + +async fn run_fixture(fixture: &LlmFixture, harness: &RealHarness) -> FixtureReport { + let mut runs = Vec::with_capacity(QUORUM_RUNS); + let mut passed_runs = 0; + + for run_index in 1..=QUORUM_RUNS { + let report = run_fixture_once(fixture, run_index, harness).await; + if report.passed { + passed_runs += 1; + } + runs.push(report); + } + + FixtureReport { + name: fixture.name.to_string(), + description: fixture.description.to_string(), + passed_runs, + total_runs: QUORUM_RUNS, + runs, + } +} + +async fn run_llm_eval(harness: &RealHarness) -> LlmEvalReport { + let fixtures = build_fixtures(); + let mut reports = Vec::with_capacity(fixtures.len()); + + for fixture in &fixtures { + reports.push(run_fixture(fixture, harness).await); + } + + LlmEvalReport { + suite: "llm", + provider_name: harness.llm_provider_name.clone(), + encoder_model_id: harness.encoder_model_id.clone(), + model_id: harness.llm_model_id.clone(), + nearest_neighbor_count: NEAREST_NEIGHBOR_COUNT, + quorum_runs: QUORUM_RUNS, + quorum_required: QUORUM_REQUIRED, + fixtures: reports, + } +} + +#[tokio::test] +#[ignore] +async fn real_llm_functional_eval() { + if !evals_enabled("real_llm_functional_eval") { + return; + } + + let _guard = real_eval_lock().lock().await; + let harness = initialize_real_harness(); + let report = run_llm_eval(&harness).await; + let pretty = serde_json::to_string_pretty(&report).expect("serialize llm eval report"); + println!("{pretty}"); + + for fixture in &report.fixtures { + assert!(fixture.passed_runs >= QUORUM_REQUIRED, "{pretty}"); + } +} diff --git a/memory-bank-server/src/retrieval_eval.rs b/memory-bank-server/src/retrieval_eval.rs new file mode 100644 index 0000000..f454287 --- /dev/null +++ b/memory-bank-server/src/retrieval_eval.rs @@ -0,0 +1,768 @@ +use crate::actor::retrieve_with_encoder; +use crate::config::EncoderProviderConfig; +use crate::db::MemoryDb; +use crate::encoder::{self, EmbeddingInput, EncoderClient}; +use memory_bank_app::{AppPaths, DEFAULT_FASTEMBED_MODEL}; +use serde::Serialize; +use std::collections::{BTreeSet, HashMap}; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::sync::OnceLock; +use std::time::Instant; +use tokio::sync::Mutex; + +const RETRIEVAL_EVAL_ENV: &str = "MEMORY_BANK_RETRIEVAL_EVALS"; +const NOTE_BATCH_SIZE: usize = 256; +const FIXED_TIMESTAMP: &str = "2026-03-30T00:00:00Z"; + +#[derive(Debug, Clone)] +struct SeedNote { + slug: String, + content: String, + context: String, + keywords: Vec, + tags: Vec, + linked_to: Vec, +} + +impl SeedNote { + fn embedding_input(&self) -> EmbeddingInput<'_> { + EmbeddingInput { + content: &self.content, + keywords: &self.keywords, + tags: &self.tags, + context: &self.context, + } + } +} + +#[derive(Debug, Clone)] +struct QueryCase { + name: String, + query: String, + expected_slugs: Vec, + expected_linked_slug: Option, +} + +#[derive(Debug)] +struct EvalCorpus { + notes: Vec, + queries: Vec, +} + +struct SeededCorpus { + db: MemoryDb, + slug_by_content: HashMap, + slug_by_id: HashMap, + note_count: usize, +} + +#[derive(Debug, Serialize)] +struct QueryEvalReport { + name: String, + query: String, + expected_slugs: Vec, + returned_slugs: Vec, + knn_returned_slugs: Vec, + recall: f64, + reciprocal_rank: f64, + knn_reciprocal_rank: f64, + linked_hit: Option, + duration_ms: f64, + knn_matches: usize, + expanded_matches: usize, + returned_count: usize, + link_expansion_inflation: f64, +} + +#[derive(Debug, Serialize)] +struct MatrixRowReport { + label: String, + note_count: usize, + query_count: usize, + nearest_neighbor_count: i32, + mean_recall: f64, + mean_reciprocal_rank: f64, + mean_knn_reciprocal_rank: f64, + linked_hit_rate: Option, + query_p50_ms: f64, + query_p95_ms: f64, + total_runtime_ms: f64, + avg_link_expansion_inflation: f64, + queries: Vec, +} + +#[derive(Debug, Serialize)] +struct GoldThresholds { + min_recall_at_5: f64, + min_recall_at_10: f64, + min_mrr_at_10: f64, + min_linked_hit_rate_at_10: f64, +} + +#[derive(Debug, Serialize)] +struct GoldEvalReport { + suite: &'static str, + encoder_model: String, + note_count: usize, + family_count: usize, + thresholds: GoldThresholds, + rows: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct GoldFamilySpec { + slug: &'static str, + canonical_term: &'static str, + alias_phrase: &'static str, + primary_focus: &'static str, + support_focus: &'static str, + distractor_focus: &'static str, + exact_query: &'static str, + alias_query: &'static str, + keywords: [&'static str; 3], + tags: [&'static str; 3], + linked: bool, +} + +const GOLD_FAMILY_SPECS: &[GoldFamilySpec] = &[ + GoldFamilySpec { + slug: "tokio-backpressure", + canonical_term: "Tokio mpsc backpressure", + alias_phrase: "bounded async queue pressure", + primary_focus: "bounded senders have to slow down when the receiver lags behind", + support_focus: "producer throttling and channel sizing in Rust services", + distractor_focus: "oneshot reply routing for a single response path", + exact_query: "tokio mpsc backpressure", + alias_query: "How do bounded Rust message queues slow producers when receivers fall behind?", + keywords: ["tokio", "mpsc", "backpressure"], + tags: ["rust", "async", "queue"], + linked: true, + }, + GoldFamilySpec { + slug: "sqlite-vec", + canonical_term: "sqlite-vec nearest-neighbor retrieval", + alias_phrase: "embedding distance lookup in SQLite", + primary_focus: "semantic matches are ordered by vector distance inside a local SQLite index", + support_focus: "query-side embeddings feed top-k neighbor selection before graph expansion", + distractor_focus: "SQL migration ordering and transactional schema setup", + exact_query: "sqlite vec nearest neighbor retrieval", + alias_query: "semantic lookup by embedding distance in SQLite", + keywords: ["sqlite-vec", "embedding", "knn"], + tags: ["database", "retrieval", "vector-search"], + linked: true, + }, + GoldFamilySpec { + slug: "trace-correlation", + canonical_term: "HTTP trace correlation header", + alias_phrase: "request-scoped trace identifier", + primary_focus: "incoming requests carry a header that keeps logs and spans tied together", + support_focus: "middleware propagates the same identifier through downstream handlers", + distractor_focus: "listener bind addresses and local development ports", + exact_query: "HTTP trace correlation header", + alias_query: "how do we keep one request identifier attached to every server span", + keywords: ["http", "trace", "correlation"], + tags: ["observability", "http", "middleware"], + linked: true, + }, + GoldFamilySpec { + slug: "staged-rollout", + canonical_term: "staged release safety guard", + alias_phrase: "progressive rollout kill switch", + primary_focus: "a feature flag rollout pauses automatically when user-facing health degrades", + support_focus: "operators keep a fast rollback and kill switch during partial release", + distractor_focus: "metric naming conventions for product experiments", + exact_query: "staged release safety guard", + alias_query: "partial deployment kill switch for a risky feature", + keywords: ["feature-flag", "rollout", "rollback"], + tags: ["release", "safety", "operations"], + linked: true, + }, + GoldFamilySpec { + slug: "cdc-stream", + canonical_term: "logical replication slot lag", + alias_phrase: "change-data-capture stream delay", + primary_focus: "replication slots can fall behind and increase retained WAL volume", + support_focus: "operators monitor slot lag before downstream consumers drift too far", + distractor_focus: "vacuum freeze policy for long-lived tables", + exact_query: "logical replication slot lag", + alias_query: "change-data-capture stream delay in Postgres", + keywords: ["postgres", "replication", "slot-lag"], + tags: ["database", "replication", "operations"], + linked: true, + }, + GoldFamilySpec { + slug: "pod-eviction", + canonical_term: "voluntary eviction guardrail", + alias_phrase: "pod disruption budget protection", + primary_focus: "cluster maintenance should not evict too many replicas at once", + support_focus: "node drains respect availability budgets during rolling operations", + distractor_focus: "CPU and memory requests for batch workers", + exact_query: "voluntary eviction guardrail", + alias_query: "how do we stop a node drain from removing too many pods", + keywords: ["kubernetes", "eviction", "pdb"], + tags: ["kubernetes", "availability", "operations"], + linked: true, + }, + GoldFamilySpec { + slug: "multipart-upload", + canonical_term: "multipart upload resume", + alias_phrase: "chunked object transfer restart", + primary_focus: "large object uploads should continue from the completed parts after a failure", + support_focus: "retry logic keeps successful parts and only resends missing chunks", + distractor_focus: "lifecycle expiration rules for stale buckets", + exact_query: "multipart upload resume", + alias_query: "resume a chunked object transfer after the network drops", + keywords: ["s3", "multipart", "retry"], + tags: ["storage", "transfer", "resilience"], + linked: false, + }, + GoldFamilySpec { + slug: "api-compat", + canonical_term: "contract-safe API evolution", + alias_phrase: "OpenAPI additive compatibility", + primary_focus: "schema updates should stay additive so older clients keep working", + support_focus: "new fields and optional enums roll out without breaking callers", + distractor_focus: "example snippets inside documentation pages", + exact_query: "contract-safe API evolution", + alias_query: "OpenAPI additive compatibility for an existing client", + keywords: ["openapi", "schema", "compatibility"], + tags: ["api", "schema", "compatibility"], + linked: false, + }, + GoldFamilySpec { + slug: "cache-stampede", + canonical_term: "thundering herd prevention", + alias_phrase: "cache stampede control", + primary_focus: "only one worker should recompute an expired value while others wait", + support_focus: "single-flight coordination reduces repeated cache regeneration work", + distractor_focus: "picking an LRU or LFU eviction policy", + exact_query: "thundering herd prevention", + alias_query: "cache stampede control for a hot key", + keywords: ["cache", "stampede", "single-flight"], + tags: ["cache", "performance", "concurrency"], + linked: false, + }, + GoldFamilySpec { + slug: "alert-dedup", + canonical_term: "incident noise suppression", + alias_phrase: "alert deduplication", + primary_focus: "repeat pages from the same failure should collapse into one ongoing incident", + support_focus: "grouping rules cut down repeated pager notifications", + distractor_focus: "handoff timing between on-call rotations", + exact_query: "incident noise suppression", + alias_query: "alert deduplication for the same outage", + keywords: ["alerts", "deduplication", "on-call"], + tags: ["observability", "incident-response", "operations"], + linked: false, + }, + GoldFamilySpec { + slug: "refresh-rotation", + canonical_term: "refresh token rotation reuse detection", + alias_phrase: "credential renewal replay check", + primary_focus: "a rotated refresh token should be invalidated if an old token is replayed", + support_focus: "security systems flag reuse when a stale credential appears again", + distractor_focus: "scope descriptions on the consent screen", + exact_query: "refresh token rotation reuse detection", + alias_query: "detect replay of an older refresh credential", + keywords: ["oauth", "refresh-token", "rotation"], + tags: ["security", "authentication", "token-management"], + linked: false, + }, + GoldFamilySpec { + slug: "type-two-dimension", + canonical_term: "historical attribute versioning", + alias_phrase: "slowly changing dimension type two", + primary_focus: "analytics systems keep prior attribute values by writing a new versioned row", + support_focus: "effective dates track when a warehouse dimension changed", + distractor_focus: "partition pruning for time-range scans", + exact_query: "historical attribute versioning", + alias_query: "slowly changing dimension type two rows in a warehouse", + keywords: ["warehouse", "dimension", "history"], + tags: ["analytics", "data-warehouse", "modeling"], + linked: false, + }, +]; + +pub(crate) fn real_eval_lock() -> &'static Mutex<()> { + static EVAL_LOCK: OnceLock> = OnceLock::new(); + EVAL_LOCK.get_or_init(|| Mutex::new(())) +} + +fn evals_enabled(test_name: &str) -> bool { + if env::var(RETRIEVAL_EVAL_ENV).ok().as_deref() == Some("1") { + true + } else { + eprintln!("skipping {test_name} because {RETRIEVAL_EVAL_ENV}=1 is not set"); + false + } +} + +fn default_models_dir() -> PathBuf { + let paths = AppPaths::from_system().expect("resolve app paths"); + paths.ensure_base_dirs().expect("create app dirs"); + let models_dir = paths.models_dir(); + fs::create_dir_all(&models_dir).expect("create models dir"); + models_dir +} + +fn initialize_real_encoder() -> encoder::InitializedEncoder { + let model = env::var("MEMORY_BANK_FASTEMBED_MODEL") + .unwrap_or_else(|_| DEFAULT_FASTEMBED_MODEL.to_string()); + encoder::initialize( + EncoderProviderConfig::FastEmbed { model }, + &default_models_dir(), + ) + .expect("initialize real fastembed encoder") +} + +async fn seed_corpus(notes: &[SeedNote], encoder: &EncoderClient) -> SeededCorpus { + assert!(!notes.is_empty(), "eval corpus must contain notes"); + + let mut chunks = notes.chunks(NOTE_BATCH_SIZE); + let first_chunk = chunks.next().expect("first note chunk"); + let first_embeddings = encode_note_chunk(encoder, first_chunk).await; + let dimension = first_embeddings + .first() + .map(std::vec::Vec::len) + .expect("non-empty first embedding batch"); + let db = MemoryDb::open_in_memory_for_tests(dimension) + .await + .expect("open eval db"); + + let mut id_by_slug = HashMap::with_capacity(notes.len()); + let mut slug_by_content = HashMap::with_capacity(notes.len()); + let mut slug_by_id = HashMap::with_capacity(notes.len()); + + insert_note_chunk( + &db, + first_chunk, + &first_embeddings, + &mut id_by_slug, + &mut slug_by_content, + &mut slug_by_id, + ) + .await; + + for chunk in chunks { + let embeddings = encode_note_chunk(encoder, chunk).await; + insert_note_chunk( + &db, + chunk, + &embeddings, + &mut id_by_slug, + &mut slug_by_content, + &mut slug_by_id, + ) + .await; + } + + let mut unique_links = BTreeSet::new(); + for note in notes { + for target in ¬e.linked_to { + let left = note.slug.as_str().min(target.as_str()).to_string(); + let right = note.slug.as_str().max(target.as_str()).to_string(); + unique_links.insert((left, right)); + } + } + + if !unique_links.is_empty() { + let mut tx = db.begin().await.expect("begin link tx"); + for (left, right) in unique_links { + let left_id = *id_by_slug + .get(&left) + .unwrap_or_else(|| panic!("missing left link slug {left}")); + let right_id = *id_by_slug + .get(&right) + .unwrap_or_else(|| panic!("missing right link slug {right}")); + db.insert_bidirectional_links(&mut tx, left_id, right_id) + .await + .expect("insert eval link"); + } + tx.commit().await.expect("commit link tx"); + } + + SeededCorpus { + db, + slug_by_content, + slug_by_id, + note_count: notes.len(), + } +} + +async fn encode_note_chunk(encoder: &EncoderClient, chunk: &[SeedNote]) -> Vec> { + let payloads: Vec> = chunk.iter().map(SeedNote::embedding_input).collect(); + encoder + .encode_memories(&payloads) + .await + .expect("encode note chunk") +} + +async fn insert_note_chunk( + db: &MemoryDb, + chunk: &[SeedNote], + embeddings: &[Vec], + id_by_slug: &mut HashMap, + slug_by_content: &mut HashMap, + slug_by_id: &mut HashMap, +) { + let mut tx = db.begin().await.expect("begin note tx"); + for (note, embedding) in chunk.iter().zip(embeddings) { + let keywords = serde_json::to_string(¬e.keywords).expect("keywords json"); + let tags = serde_json::to_string(¬e.tags).expect("tags json"); + let id = db + .insert_memory( + &mut *tx, + ¬e.content, + FIXED_TIMESTAMP, + ¬e.context, + &keywords, + &tags, + ) + .await + .expect("insert eval note"); + db.insert_embedding(&mut *tx, id, &crate::encoder::embedding_to_bytes(embedding)) + .await + .expect("insert eval embedding"); + id_by_slug.insert(note.slug.clone(), id); + slug_by_content.insert(note.content.clone(), note.slug.clone()); + slug_by_id.insert(id, note.slug.clone()); + } + tx.commit().await.expect("commit note tx"); +} + +async fn run_matrix_row( + label: impl Into, + corpus: &EvalCorpus, + encoder: &EncoderClient, + nearest_neighbor_count: i32, +) -> MatrixRowReport { + let seeded = seed_corpus(&corpus.notes, encoder).await; + let suite_started_at = Instant::now(); + let mut queries = Vec::with_capacity(corpus.queries.len()); + + for query_case in &corpus.queries { + let query_started_at = Instant::now(); + let outcome = retrieve_with_encoder( + &seeded.db, + encoder, + nearest_neighbor_count, + &query_case.query, + ) + .await + .expect("run retrieval eval query"); + let duration_ms = query_started_at.elapsed().as_secs_f64() * 1000.0; + let returned_slugs: Vec = outcome + .notes + .iter() + .map(|note| { + seeded + .slug_by_content + .get(¬e.content) + .cloned() + .unwrap_or_else(|| format!("unmapped:{}", note.content)) + }) + .collect(); + let knn_returned_slugs: Vec = outcome + .knn_ids + .iter() + .map(|id| { + seeded + .slug_by_id + .get(id) + .cloned() + .unwrap_or_else(|| format!("unmapped-id:{id}")) + }) + .collect(); + let returned_set: BTreeSet<&str> = returned_slugs.iter().map(String::as_str).collect(); + let hits = query_case + .expected_slugs + .iter() + .filter(|slug| returned_set.contains(slug.as_str())) + .count(); + let recall = hits as f64 / query_case.expected_slugs.len() as f64; + let reciprocal_rank = query_case + .expected_slugs + .iter() + .filter_map(|slug| { + returned_slugs + .iter() + .position(|returned| returned == slug) + .map(|index| 1.0 / (index + 1) as f64) + }) + .fold(0.0, f64::max); + let knn_reciprocal_rank = query_case + .expected_slugs + .iter() + .filter_map(|slug| { + knn_returned_slugs + .iter() + .position(|returned| returned == slug) + .map(|index| 1.0 / (index + 1) as f64) + }) + .fold(0.0, f64::max); + let linked_hit = query_case + .expected_linked_slug + .as_ref() + .map(|slug| returned_set.contains(slug.as_str())); + let inflation = if outcome.knn_matches == 0 { + 0.0 + } else { + outcome.expanded_matches as f64 / outcome.knn_matches as f64 + }; + + queries.push(QueryEvalReport { + name: query_case.name.clone(), + query: query_case.query.clone(), + expected_slugs: query_case.expected_slugs.clone(), + returned_slugs, + knn_returned_slugs, + recall, + reciprocal_rank, + knn_reciprocal_rank, + linked_hit, + duration_ms, + knn_matches: outcome.knn_matches, + expanded_matches: outcome.expanded_matches, + returned_count: outcome.notes.len(), + link_expansion_inflation: inflation, + }); + } + + MatrixRowReport { + label: label.into(), + note_count: seeded.note_count, + query_count: queries.len(), + nearest_neighbor_count, + mean_recall: mean(queries.iter().map(|query| query.recall)), + mean_reciprocal_rank: mean(queries.iter().map(|query| query.reciprocal_rank)), + mean_knn_reciprocal_rank: mean(queries.iter().map(|query| query.knn_reciprocal_rank)), + linked_hit_rate: linked_hit_rate(&queries), + query_p50_ms: percentile( + queries.iter().map(|query| query.duration_ms).collect(), + 0.50, + ), + query_p95_ms: percentile( + queries.iter().map(|query| query.duration_ms).collect(), + 0.95, + ), + total_runtime_ms: suite_started_at.elapsed().as_secs_f64() * 1000.0, + avg_link_expansion_inflation: mean( + queries.iter().map(|query| query.link_expansion_inflation), + ), + queries, + } +} + +fn build_gold_corpus() -> EvalCorpus { + let mut notes = Vec::with_capacity(GOLD_FAMILY_SPECS.len() * 3); + let mut queries = Vec::with_capacity(GOLD_FAMILY_SPECS.len() * 2); + + for spec in GOLD_FAMILY_SPECS { + let core_slug = format!("{}-core", spec.slug); + let companion_slug = format!("{}-companion", spec.slug); + let distractor_slug = format!("{}-distractor", spec.slug); + + notes.push(SeedNote { + slug: core_slug.clone(), + content: format!( + "{} is the phrase we use when {}.", + spec.canonical_term, spec.primary_focus + ), + context: format!( + "Team shorthand also calls this \"{}\" when discussing {}.", + spec.alias_phrase, spec.support_focus + ), + keywords: vec![ + spec.keywords[0].to_string(), + spec.keywords[1].to_string(), + spec.keywords[2].to_string(), + spec.alias_phrase.to_string(), + ], + tags: spec.tags.iter().map(|tag| (*tag).to_string()).collect(), + linked_to: if spec.linked { + vec![companion_slug.clone()] + } else { + Vec::new() + }, + }); + notes.push(SeedNote { + slug: companion_slug.clone(), + content: format!( + "A companion note for {} focuses on {}.", + spec.canonical_term, spec.support_focus + ), + context: format!( + "Operators often describe this companion note with the phrase \"{}\".", + spec.alias_phrase + ), + keywords: vec![ + spec.keywords[0].to_string(), + spec.keywords[1].to_string(), + spec.alias_phrase.to_string(), + ], + tags: vec![ + spec.tags[0].to_string(), + spec.tags[1].to_string(), + "companion-note".to_string(), + ], + linked_to: if spec.linked { + vec![core_slug.clone()] + } else { + Vec::new() + }, + }); + notes.push(SeedNote { + slug: distractor_slug.clone(), + content: format!( + "{} is adjacent work in the same area, but it is not the same as {}.", + spec.distractor_focus, spec.canonical_term + ), + context: format!( + "This note shares the broad tags for {} without answering the same retrieval need.", + spec.canonical_term + ), + keywords: vec![ + spec.tags[0].to_string(), + spec.tags[1].to_string(), + "adjacent".to_string(), + ], + tags: vec![ + spec.tags[0].to_string(), + spec.tags[1].to_string(), + "adjacent-note".to_string(), + ], + linked_to: Vec::new(), + }); + + let mut expected_exact = vec![core_slug.clone()]; + let mut expected_alias = vec![core_slug.clone()]; + let expected_linked = if spec.linked { + expected_exact.push(companion_slug.clone()); + expected_alias.push(companion_slug.clone()); + Some(companion_slug) + } else { + None + }; + + queries.push(QueryCase { + name: format!("{}-exact", spec.slug), + query: spec.exact_query.to_string(), + expected_slugs: expected_exact, + expected_linked_slug: expected_linked.clone(), + }); + queries.push(QueryCase { + name: format!("{}-alias", spec.slug), + query: spec.alias_query.to_string(), + expected_slugs: expected_alias, + expected_linked_slug: expected_linked, + }); + } + + EvalCorpus { notes, queries } +} + +async fn run_gold_eval(encoder_model: String, encoder: &EncoderClient) -> GoldEvalReport { + let corpus = build_gold_corpus(); + let note_count = corpus.notes.len(); + let mut rows = Vec::new(); + + for nearest_neighbor_count in [5, 10, 20] { + rows.push( + run_matrix_row( + format!("gold@k={nearest_neighbor_count}"), + &corpus, + encoder, + nearest_neighbor_count, + ) + .await, + ); + } + + GoldEvalReport { + suite: "gold", + encoder_model, + note_count, + family_count: GOLD_FAMILY_SPECS.len(), + thresholds: GoldThresholds { + min_recall_at_5: 0.80, + min_recall_at_10: 0.90, + min_mrr_at_10: 0.75, + min_linked_hit_rate_at_10: 0.80, + }, + rows, + } +} + +fn linked_hit_rate(queries: &[QueryEvalReport]) -> Option { + let linked: Vec = queries + .iter() + .filter_map(|query| query.linked_hit) + .collect(); + if linked.is_empty() { + None + } else { + Some(mean(linked.iter().map(|hit| u8::from(*hit) as f64))) + } +} + +fn mean(values: impl Iterator) -> f64 { + let values: Vec = values.collect(); + if values.is_empty() { + 0.0 + } else { + values.iter().sum::() / values.len() as f64 + } +} + +fn percentile(mut values: Vec, quantile: f64) -> f64 { + if values.is_empty() { + return 0.0; + } + values.sort_by(f64::total_cmp); + let index = (((values.len() - 1) as f64) * quantile).round() as usize; + values[index] +} + +#[tokio::test] +#[ignore] +async fn real_encoder_gold_retrieval_eval() { + if !evals_enabled("real_encoder_gold_retrieval_eval") { + return; + } + + let _guard = real_eval_lock().lock().await; + let initialized = initialize_real_encoder(); + let report = run_gold_eval(initialized.model_id.clone(), &initialized.client).await; + let pretty = serde_json::to_string_pretty(&report).expect("serialize gold report"); + println!("{pretty}"); + + let row_at_5 = report + .rows + .iter() + .find(|row| row.nearest_neighbor_count == 5) + .expect("gold k=5 row"); + let row_at_10 = report + .rows + .iter() + .find(|row| row.nearest_neighbor_count == 10) + .expect("gold k=10 row"); + + assert!( + row_at_5.mean_recall >= report.thresholds.min_recall_at_5, + "{pretty}" + ); + assert!( + row_at_10.mean_recall >= report.thresholds.min_recall_at_10, + "{pretty}" + ); + assert!( + row_at_10.mean_reciprocal_rank >= report.thresholds.min_mrr_at_10, + "{pretty}" + ); + assert!( + row_at_10.linked_hit_rate.unwrap_or(0.0) >= report.thresholds.min_linked_hit_rate_at_10, + "{pretty}" + ); +} diff --git a/memory-bank-server/src/startup_state.rs b/memory-bank-server/src/startup_state.rs new file mode 100644 index 0000000..889e7b9 --- /dev/null +++ b/memory-bank-server/src/startup_state.rs @@ -0,0 +1,49 @@ +use memory_bank_app::{ServerStartupPhase, ServerStartupState}; +use std::fs; +use std::io; +use std::path::PathBuf; + +pub(crate) struct StartupStateTracker { + path: PathBuf, + pid: u32, + namespace: String, +} + +impl StartupStateTracker { + pub(crate) fn new(path: PathBuf, namespace: impl Into) -> Self { + Self { + path, + pid: std::process::id(), + namespace: namespace.into(), + } + } + + pub(crate) fn begin_reindex(&self, memory_count: usize) -> Result { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent)?; + } + + let state = ServerStartupState { + pid: self.pid, + namespace: self.namespace.clone(), + phase: ServerStartupPhase::Reindexing, + memory_count: Some(memory_count), + }; + let payload = serde_json::to_vec_pretty(&state).map_err(io::Error::other)?; + fs::write(&self.path, payload)?; + + Ok(ReindexingGuard { + path: self.path.clone(), + }) + } +} + +pub(crate) struct ReindexingGuard { + path: PathBuf, +} + +impl Drop for ReindexingGuard { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +}