diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index c808685..b04c846 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Security reports - url: https://github.com/tinyhumansai/rust-template/security/policy + url: https://github.com/tinyhumansai/tinymemory/security/policy about: Please do not report vulnerabilities through public issues. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62625b2..16c518f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: # This job executes repository code (cargo build/test); don't persist # the token in git config. persist-credentials: false - submodules: true + submodules: recursive - uses: dtolnay/rust-toolchain@stable with: @@ -56,7 +56,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: true + submodules: recursive - uses: dtolnay/rust-toolchain@stable @@ -74,14 +74,14 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: true + submodules: recursive - name: Read rust-version from Cargo.toml id: msrv run: | set -euo pipefail msrv="$(cargo metadata --format-version 1 --no-deps \ - | jq -r '.packages[0].rust_version')" + | jq -r '.packages[] | select(.name == "tinymemory") | .rust_version')" if [[ -z "$msrv" || "$msrv" == "null" ]]; then echo "package.rust-version is not set in Cargo.toml" >&2 exit 1 @@ -104,7 +104,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: true + submodules: recursive - name: Check advisories, licenses, bans, and sources uses: EmbarkStudios/cargo-deny-action@v2 diff --git a/.gitmodules b/.gitmodules index da09a74..64714b3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,7 @@ path = vendor/tinybus url = https://github.com/tinyhumansai/tinybus branch = main +[submodule "vendor/tinycortex"] + path = vendor/tinycortex + url = https://github.com/tinyhumansai/tinycortex.git + branch = main diff --git a/AGENTS.md b/AGENTS.md index 93479ff..8bcb325 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,30 +4,19 @@ This file is the single source of truth for how humans and coding agents work in this repository. `CLAUDE.md` is a symlink to this file, so every agent reads the same instructions. -When you generate a new project from this template, keep this file and adapt -the project-specific parts (crate name, module map, feature flags, commands). -Delete guidance that no longer applies rather than leaving it to rot. - -## Template Checklist - -Do this once, in a single commit, before writing feature code: - -- [ ] Set `name`, `description`, `repository`, `keywords`, and `categories` in - `Cargo.toml`. -- [ ] Rename the crate references in `README.md`, `src/lib.rs`, `examples/`, - and `tests/` (search for `rust_template` and `rust-template`). -- [ ] Replace the placeholder `greeting` module with the first real feature - area, keeping the `mod.rs` / `types.rs` / `test.rs` layout. -- [ ] Confirm `license` and `LICENSE` match the project's intended license. -- [ ] Update the security contact in `SECURITY.md`. -- [ ] Replace `ROADMAP.md` with the real plan, or delete it. -- [ ] Decide whether the project uses TinyBus. Keep `vendor/tinybus` pinned and - wire the required crate/features, or remove the submodule deliberately. -- [ ] Rewrite the "Project Structure" section below to describe this crate. +Adapt the project-specific parts as the crate changes, and delete guidance that +no longer applies rather than leaving it to rot. ## Project Structure -This is a Rust 2024 library crate rooted at `Cargo.toml`. +This is a Cargo **workspace**: the `tinymemory` facade at the root, the +`tinymemory-api` contract in `api/`, and one engine adapter per directory under +`adapters/`. Engines themselves are submodules under `vendor/`, excluded from +the workspace. + +See [`README.md`](README.md) for the layout and the rules that govern it — in +particular, why policy stays in the host and why adapters name their engines by +version requirement rather than by path. ```text src/ diff --git a/Cargo.lock b/Cargo.lock index 4c271f3..8adec7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,64 +2,1895 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279259b0ac81c89d11c290495fdcfa96ea3643b7df311c138b6fe8ca5237f0f8" +dependencies = [ + "idna_mapping", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna_mapping" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11c13906586a4b339310541a274dd927aff6fcbb5b8e3af90634c4b31681c792" +dependencies = [ + "unicode-joining-type", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "proc-macro2" version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinyagents" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d9746cb9ff6f37646b2c91eebe3dee775a56083d21ed41446a54c930efaf056" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "reqwest", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "tinycortex" +version = "0.1.1" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures", + "log", + "parking_lot", + "rand", + "regex", + "rusqlite", + "schemars", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror", + "tinyagents", + "tinycortex-api", + "toml", + "uuid", + "walkdir", +] + +[[package]] +name = "tinycortex-api" +version = "0.1.1" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror", + "uuid", +] + +[[package]] +name = "tinymemory" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "log", + "serde", + "serde_json", + "tinymemory-api", + "tokio", +] + +[[package]] +name = "tinymemory-api" +version = "0.1.1" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror", + "uuid", +] + +[[package]] +name = "tinymemory-tinycortex" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "tinycortex", + "tinymemory", + "tinymemory-api", + "tokio", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ - "unicode-ident", + "tinyvec_macros", ] [[package]] -name = "quote" -version = "1.0.47" +name = "tinyvec_macros" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] -name = "rust-template" -version = "0.1.0" +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "thiserror", + "rustls", + "tokio", ] [[package]] -name = "syn" -version = "3.0.3" +name = "tokio-util" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", ] [[package]] -name = "thiserror" -version = "2.0.20" +name = "toml" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ - "thiserror-impl", + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] -name = "thiserror-impl" -version = "2.0.20" +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-joining-type" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d00a78170970967fdb83f9d49b92f959ab2bb829186b113e4f4604ad98e180" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index c897905..711a854 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,23 @@ +[workspace] +members = [".", "api", "adapters/tinycortex"] +default-members = [".", "api", "adapters/tinycortex"] +# `vendor/` holds engine submodules (tinycortex, tinybus), each of which is its +# own workspace with its own lockfile. Same exclusion `vendor/tinycortex` uses +# for its own nested vendor directory. +exclude = ["vendor"] + [package] -name = "rust-template" +name = "tinymemory" version = "0.1.0" -edition = "2024" -rust-version = "1.85" -license = "GPL-3.0-only" -description = "A production-ready Rust library template." -repository = "https://github.com/tinyhumansai/rust-template" -documentation = "https://docs.rs/rust-template" +edition = "2021" +rust-version = "1.96" +license = "MIT" +description = "Engine-neutral memory contract, driver registry, and engine adapters" +repository = "https://github.com/tinyhumansai/tinymemory" +documentation = "https://docs.rs/tinymemory" readme = "README.md" -keywords = ["template"] -categories = ["development-tools"] +keywords = ["memory", "agent", "llm", "retrieval"] +categories = ["database"] # Keep the published package to what a consumer actually needs. exclude = [ ".github/", @@ -22,17 +30,35 @@ exclude = [ ] [dependencies] -# Derive macros for the crate-wide error type in `src/error/mod.rs`. Every -# dependency entry should carry a comment like this one saying why it is here. -thiserror = "2" +# The contract itself. Re-exported wholesale from `src/lib.rs` so a host takes +# one dependency rather than two, and so `tinymemory::MemoryProvider` and +# `tinymemory_api::provider::MemoryProvider` are the same type. +tinymemory-api = { path = "api", version = "0.1.1" } +# The mandatory capability families are `async fn`s on object-safe traits. +async-trait = "0.1" +# `Memory` is anyhow-typed; `mandatory::engine_error` maps it onto `MemoryError`. +anyhow = "1" +# Diagnostics on the shared store/recall/export paths. +log = "0.4" +# `ExportRecord::payload` is a `serde_json::Value`. +serde_json = "1" + +# `DriverClass` is read out of a host's config file, so its serde form is the +# config form and is pinned by a test. +serde = { version = "1", features = ["derive"] } [dev-dependencies] +# The mandatory-family tests are async. +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } [features] default = [] -# Lints apply to the whole crate and to every target. CI runs clippy with -# `-D warnings`, so anything set to "warn" here fails the build in CI. +# Lints apply to this package only, deliberately. `api/` is contract code moved +# verbatim from `tinycortex-api` and is held byte-identical; subjecting it to a +# stricter lint set than it was written under would force cosmetic edits through +# a surface whose serde representations and enum wire strings are persisted on +# disk. Adapter crates opt in individually. [lints.rust] unsafe_code = "forbid" missing_docs = "warn" @@ -61,6 +87,13 @@ must_use_candidate = "warn" broken_intra_doc_links = "warn" private_intra_doc_links = "warn" +# The engine adapters name their engines by version requirement, not by path, +# so a host that already pins its own engine checkout unifies onto one copy +# through its own patch table. These entries are what make a *standalone* build +# of this workspace resolve them to the nested `vendor/` submodules. +[patch.crates-io] +tinycortex = { path = "vendor/tinycortex" } + [profile.release] # Cross-crate optimization and smaller, faster binaries for release builds. lto = "thin" diff --git a/README.md b/README.md index 5fa22ad..17604be 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,86 @@ -# Rust Template +# TinyMemory -A production-ready Rust 2024 library template used by TinyHumans AI. It ships -the module layout, lint configuration, error handling, testing, documentation, -CI, and release workflow that every new crate in this organization starts from — -plus one small feature module that demonstrates the conventions end to end. +The engine-neutral memory layer for TinyHumans agents. -## Use This Template - -Choose **Use this template** on GitHub, create a repository, then work through -the checklist at the top of [`AGENTS.md`](AGENTS.md): - -- update the package name, description, repository, keywords, and categories in - `Cargo.toml`; -- update this README and the crate documentation in `src/lib.rs`; -- replace the placeholder `greeting` module with the first real feature area; -- update the security contact and repository links in the community files; -- replace `ROADMAP.md` with the real plan, or delete it; -- change the license if GPL-3.0-only is not appropriate. - -Search for `rust-template` and `rust_template` to find every remaining -template-specific value. - -## What You Get - -| Area | What is configured | -| --- | --- | -| Layout | Directory modules with `mod.rs` / `types.rs` / `test.rs`, a crate-wide error type, integration tests, and a runnable example | -| Lints | `unsafe_code` forbidden, `missing_docs`, clippy `all` + `pedantic`, no `unwrap`/`expect`/`panic`/`todo` in library code — all declared in `[lints]` so local and CI runs agree | -| CI | Format, clippy, build, test (default and all features), rustdoc with `-D warnings`, an MSRV build, and a `cargo-deny` supply-chain check | -| Release | Manual `workflow_dispatch` bump that validates, versions, tags, and publishes to crates.io | -| Community | Issue and pull request templates, Dependabot, contributing, security, support, and code of conduct docs | -| Agents | [`AGENTS.md`](AGENTS.md) as the single source of truth, symlinked as `CLAUDE.md`, plus a `.claude/settings.json` allowlist for the standard commands | -| Vendor | TinyBus pinned as the `vendor/tinybus` submodule, initialized by CI and release workflows | +A host that embeds TinyMemory performs every memory operation through one +contract, and picks which engine answers it by configuration rather than by +recompiling. [TinyCortex](https://github.com/tinyhumansai/tinycortex) is the +default embedded engine; a second engine implements the same traits and binds in +its place without the host learning anything new. ## Layout ```text +api/ tinymemory-api — the contract. Dependency-light on + purpose: depending on it never drags in SQLite, git2, + reqwest, or an async runtime. src/ -├── lib.rs # crate docs + the entire public re-export surface -├── error/ -│ ├── mod.rs # crate-wide `Error` and `Result` -│ └── test.rs -└── greeting/ # one directory per feature area - ├── mod.rs # module docs, wiring, smallest useful public API - └── test.rs # module-local unit tests -tests/ -└── public_api.rs # integration tests against the public API only -examples/ -└── basic.rs # compiled and linted in CI +├── lib.rs re-exports the contract wholesale, so a host takes one +│ dependency and the types are the same types +├── registry/ driver admission — which ids exist, what class each +│ binds as, and the fail-closed external-driver gate +└── mandatory/ the three mandatory capability families, composed once + over the `Memory` storage trait +adapters/ +└── tinycortex/ the TinyCortex engine seen through the contract vendor/ -└── tinybus/ # pinned TinyBus git submodule -docs/ -├── README.md # documentation index and conventions -├── specs/ # behavior and architecture specifications -├── plans/ # implementation-ordered delivery plans -└── adr/ # immutable architecture decision records +├── tinycortex/ the engine, pinned as a submodule +└── tinybus/ pinned TinyBus submodule ``` -Feature areas use directory modules: implementation and exports live in -`mod.rs`, substantial types move to `types.rs`, and unit tests live in -`test.rs`. [`AGENTS.md`](AGENTS.md) holds the complete repository guidance, and -`CLAUDE.md` is a symlink to it so every coding agent reads one source of truth. - -## Development +## The contract -Clone with submodules, or initialize them before building: +`MemoryProvider` is an object-safe trait with **three mandatory** capability +families and **ten optional** ones. The mandatory three are supertraits, so a +driver missing any of them cannot be constructed; the optional ten are reached +through `as_ingest()` / `as_tree()` / … accessors that default to `None`, so a +minimal driver implements what it supports and inherits correct absence for +everything else. -```sh -git submodule update --init --recursive -``` +A driver's advertised set and its reachable accessors must agree. +`audit_provider` checks exactly that, which turns "advertised but not +implemented" into a detectable, testable mistake rather than a runtime surprise +on the first call. -```sh -cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -cargo build --all-targets --all-features -cargo test --all-features -cargo run --example basic -``` +Capabilities are asked **once, at bind time, and cached**: a host filters its RPC +surface and its agent-tool list from the answer, so a set that changed +afterwards would not be noticed. -Those four checks are exactly what CI runs. Optional extras: +## What lives here, and what deliberately does not -```sh -cargo doc --no-deps --all-features # CI builds this with RUSTDOCFLAGS="-D warnings" -cargo deny check all # supply-chain check; see deny.toml -``` +| Here | In the host | +| --- | --- | +| the contract; capability negotiation; driver admission; the shared mandatory families; per-engine adapters | RPC surface, agent tools, security policy, credentials, schedulers, event bus, config mapping | -## Releasing +**Policy is not here, on purpose.** Tier enforcement, scope predicates, taint +stamping, redaction, egress checks and audit belong in a decorator the *host* +owns, on the path every caller takes. A driver that could be swapped for one +that skips enforcement is the entire reason the policy layer exists. -Run the **Release** workflow from the Actions tab with a `patch`, `minor`, or -`major` bump. It revalidates the crate, bumps the version, commits, tags -`vX.Y.Z`, and publishes to crates.io. Do not hand-edit the version in -`Cargo.toml`. +## Adding an engine -## Documentation +1. Implement `tinymemory_api::traits::Memory` for the backend, **overriding + `store_with_taint`** — the trait default silently drops the taint, which + would launder externally-sourced content into internal-trust content. +2. Wrap it: `MemoryTraitProvider::new(backend, "my-engine")`. That yields a + driver advertising Core, Recall and Portability, with the four + easy-to-get-wrong parts (see `src/mandatory/mod.rs`) already handled. +3. Implement any optional families over the engine's own entry points, and + widen `capabilities()` in lockstep with the accessors. +4. Reserve the driver id: `DriverRegistry::builtin().with_reserved("my-engine", DriverClass::Embedded)`. -- [`AGENTS.md`](AGENTS.md) — repository guidelines for humans and agents -- [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to propose a change -- [`docs/specs/`](docs/specs/README.md) — behavior and architecture specs -- [`docs/plans/`](docs/plans/README.md) — test-first implementation plans -- [`docs/adr/`](docs/adr/0001-record-architecture-decisions.md) — architecture - decision records -- [`SECURITY.md`](SECURITY.md) — how to report a vulnerability +## Development -## License +```bash +git submodule update --init --recursive +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +cargo fmt --all -- --check +``` -GPL-3.0-only. See [LICENSE](LICENSE). +Engine adapters name their engines by **version requirement, not path**, so a +host that already pins its own engine checkout unifies onto one copy through its +own `[patch.crates-io]`. The workspace root patches them to the nested `vendor/` +submodules for a standalone build. A path dependency in an adapter would defeat +that and hand a host two copies of one engine with two incompatible `Memory` +traits. diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 3c2032d..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,24 +0,0 @@ -# Roadmap - -Replace this file with the real plan for the crate generated from this -template, or delete it if the project does not need a public roadmap. - -Keep it short and honest: what exists, what is next, and what is deliberately -out of scope. A roadmap that lists everything is a roadmap nobody trusts. - -## Shipped - -- module layout, crate-wide error type, and the public re-export surface -- lint configuration in `[lints]`, enforced identically locally and in CI -- CI: format, clippy, build, test, rustdoc, MSRV, and supply-chain checks -- a manual release workflow that versions, tags, and publishes to crates.io - -## Next - -- the first real feature area, replacing the placeholder `greeting` module -- module-level `README.md` and `docs/spec/` entries as modules grow - -## Out Of Scope - -- anything that cannot be tested deterministically -- convenience wrappers that hide the crate's error taxonomy from callers diff --git a/adapters/tinycortex/Cargo.toml b/adapters/tinycortex/Cargo.toml new file mode 100644 index 0000000..1fbfb57 --- /dev/null +++ b/adapters/tinycortex/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "tinymemory-tinycortex" +version = "0.1.0" +edition = "2021" +rust-version = "1.85" +license = "MIT" +description = "TinyCortex engine adapter for the TinyMemory contract" +repository = "https://github.com/tinyhumansai/tinymemory" + +[dependencies] +# The contract this adapter targets. +tinymemory = { path = "../..", version = "0.1" } +tinymemory-api = { path = "../../api", version = "0.1.1" } +# The engine being adapted. A version requirement rather than a path, so a host +# that already pins its own TinyCortex checkout unifies both onto one copy +# through its `[patch.crates-io]`; the workspace root patches it to the nested +# `vendor/tinycortex` submodule for a standalone build. A path dependency here +# would defeat that and give a host two TinyCortex crates with two incompatible +# `Memory` traits. +tinycortex = { version = "0.1", default-features = false } + +# `Memory` is an object-safe async trait. +async-trait = "0.1" +# The engine's trait surface is anyhow-typed. +anyhow = "1" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[lints.rust] +unsafe_code = "forbid" +missing_docs = "warn" +unreachable_pub = "warn" + +[lints.clippy] +all = { level = "warn", priority = -1 } +# `expect`/`panic` are denied in the library the same way the facade denies them. +unwrap_used = "warn" +expect_used = "warn" +panic = "warn" +missing_errors_doc = "warn" diff --git a/adapters/tinycortex/src/convert.rs b/adapters/tinycortex/src/convert.rs new file mode 100644 index 0000000..eb9b324 --- /dev/null +++ b/adapters/tinycortex/src/convert.rs @@ -0,0 +1,145 @@ +//! Value conversions between `tinycortex-api` and `tinymemory-api`. +//! +//! The two contracts describe the same values and, today, describe them +//! identically — `tinymemory-api` was moved out of `tinycortex-api`. They are +//! nonetheless distinct Rust types in distinct crates, so a value has to be +//! rebuilt to cross. +//! +//! ## Every conversion destructures exhaustively +//! +//! This is the whole discipline of this file, and the reason it is not written +//! with `..` or field-by-field assignment onto a `Default`. Two contracts that +//! are allowed to drift *will* drift: someone adds a field to one side, and a +//! lenient conversion silently drops it. A struct literal built from a full +//! destructuring pattern turns that into a compile error on the very next +//! build, naming the field. +//! +//! The same applies to the enums: each `match` lists every variant, so a new +//! category or a third taint level cannot fall into a catch-all arm and be +//! quietly downgraded. +//! +//! ## Taint conversion is the security-relevant one +//! +//! [`tinymemory_api::types::MemoryTaint`] records whether content came from outside. Mapping it +//! wrongly — or defaulting it on an unrecognised value — would let +//! externally-sourced content be treated as internal-trust content. Both sides +//! fail closed to `ExternalSync` when decoding an unknown persisted string, and +//! the mapping here is a total two-arm match with no default, so there is +//! nowhere for a wrong answer to come from. + +use tinycortex::memory::types as tc; +use tinymemory_api::types as tm; + +/// Converts a category to the TinyMemory contract's form. +#[must_use] +pub fn category_to_tinymemory(category: tc::MemoryCategory) -> tm::MemoryCategory { + match category { + tc::MemoryCategory::Core => tm::MemoryCategory::Core, + tc::MemoryCategory::Daily => tm::MemoryCategory::Daily, + tc::MemoryCategory::Conversation => tm::MemoryCategory::Conversation, + tc::MemoryCategory::Custom(name) => tm::MemoryCategory::Custom(name), + } +} + +/// Converts a category to the TinyCortex engine's form. +#[must_use] +pub fn category_to_tinycortex(category: tm::MemoryCategory) -> tc::MemoryCategory { + match category { + tm::MemoryCategory::Core => tc::MemoryCategory::Core, + tm::MemoryCategory::Daily => tc::MemoryCategory::Daily, + tm::MemoryCategory::Conversation => tc::MemoryCategory::Conversation, + tm::MemoryCategory::Custom(name) => tc::MemoryCategory::Custom(name), + } +} + +/// Converts provenance to the TinyMemory contract's form. +/// +/// A total match with no default arm: see the module docs on why this one may +/// not be lenient. +#[must_use] +pub fn taint_to_tinymemory(taint: tc::MemoryTaint) -> tm::MemoryTaint { + match taint { + tc::MemoryTaint::Internal => tm::MemoryTaint::Internal, + tc::MemoryTaint::ExternalSync => tm::MemoryTaint::ExternalSync, + } +} + +/// Converts provenance to the TinyCortex engine's form. +#[must_use] +pub fn taint_to_tinycortex(taint: tm::MemoryTaint) -> tc::MemoryTaint { + match taint { + tm::MemoryTaint::Internal => tc::MemoryTaint::Internal, + tm::MemoryTaint::ExternalSync => tc::MemoryTaint::ExternalSync, + } +} + +/// Converts an entry to the TinyMemory contract's form. +#[must_use] +pub fn entry_to_tinymemory(entry: tc::MemoryEntry) -> tm::MemoryEntry { + // Exhaustive destructuring: a field added to the engine's entry breaks this + // line rather than being dropped on the floor. + let tc::MemoryEntry { + id, + key, + content, + namespace, + category, + timestamp, + session_id, + score, + taint, + } = entry; + tm::MemoryEntry { + id, + key, + content, + namespace, + category: category_to_tinymemory(category), + timestamp, + session_id, + score, + taint: taint_to_tinymemory(taint), + } +} + +/// Converts a namespace summary to the TinyMemory contract's form. +#[must_use] +pub fn namespace_summary_to_tinymemory(summary: tc::NamespaceSummary) -> tm::NamespaceSummary { + let tc::NamespaceSummary { + namespace, + count, + last_updated, + } = summary; + tm::NamespaceSummary { + namespace, + count, + last_updated, + } +} + +/// The owned recall filters, in the engine's owned form. +/// +/// Returned owned rather than borrowed because the engine's `RecallOpts` +/// borrows its string fields, and a borrow of a value built inside a conversion +/// function cannot outlive the call. Callers keep this alive and borrow from it. +#[must_use] +pub fn recall_opts_to_tinycortex(opts: &tm::OwnedRecallOpts) -> tc::OwnedRecallOpts { + let tm::OwnedRecallOpts { + namespace, + category, + session_id, + min_score, + cross_session, + } = opts; + tc::OwnedRecallOpts { + namespace: namespace.clone(), + category: category.clone().map(category_to_tinycortex), + session_id: session_id.clone(), + min_score: *min_score, + cross_session: *cross_session, + } +} + +#[cfg(test)] +#[path = "convert_test.rs"] +mod test; diff --git a/adapters/tinycortex/src/convert_test.rs b/adapters/tinycortex/src/convert_test.rs new file mode 100644 index 0000000..75016ec --- /dev/null +++ b/adapters/tinycortex/src/convert_test.rs @@ -0,0 +1,170 @@ +//! Conversion tests. +//! +//! Two contracts that are allowed to drift will drift. The exhaustive +//! destructuring in `convert.rs` catches a *new* field at compile time; these +//! tests catch a *mis-mapped* one, which the compiler cannot see because every +//! field on both sides has the same type. + +#![allow(clippy::expect_used, clippy::panic)] + +use super::*; + +/// Every category must survive a round trip, including the custom variant's +/// payload — a `Custom(String)` mapped onto the wrong arm would silently +/// re-file every custom-categorised memory. +#[test] +fn every_category_round_trips() { + let cases = [ + tm::MemoryCategory::Core, + tm::MemoryCategory::Daily, + tm::MemoryCategory::Conversation, + tm::MemoryCategory::Custom("project-notes".to_string()), + ]; + for category in cases { + let round_tripped = category_to_tinymemory(category_to_tinycortex(category.clone())); + assert_eq!(round_tripped, category); + } +} + +/// The wire form is the persisted form on both sides, so a conversion that +/// round-trips the Rust value but changes the string would still corrupt a +/// store. Comparing the rendered forms catches that. +#[test] +fn category_conversion_preserves_the_persisted_spelling() { + let cases = [ + tm::MemoryCategory::Core, + tm::MemoryCategory::Daily, + tm::MemoryCategory::Conversation, + tm::MemoryCategory::Custom("x".to_string()), + ]; + for category in cases { + let engine = category_to_tinycortex(category.clone()); + assert_eq!( + engine.to_string(), + category.to_string(), + "the two contracts must agree on the persisted spelling" + ); + } +} + +/// Provenance is the security-relevant conversion: mapping `ExternalSync` onto +/// `Internal` would upgrade the trust of externally-sourced content. +#[test] +fn every_taint_round_trips_and_keeps_its_db_spelling() { + for taint in [tm::MemoryTaint::Internal, tm::MemoryTaint::ExternalSync] { + let engine = taint_to_tinycortex(taint); + assert_eq!(taint_to_tinymemory(engine), taint); + assert_eq!( + engine.as_db_str(), + taint.as_db_str(), + "the two contracts must agree on the persisted spelling" + ); + } +} + +/// `ExternalSync` must never come back as `Internal`, stated as its own +/// assertion rather than left implicit in the round trip above. +#[test] +fn external_content_is_never_laundered_into_internal_trust() { + assert_eq!( + taint_to_tinymemory(taint_to_tinycortex(tm::MemoryTaint::ExternalSync)), + tm::MemoryTaint::ExternalSync + ); + assert_ne!( + taint_to_tinymemory(taint_to_tinycortex(tm::MemoryTaint::ExternalSync)), + tm::MemoryTaint::Internal + ); +} + +#[test] +fn an_entry_round_trips_every_field() { + let engine = tc::MemoryEntry { + id: "ns/key".to_string(), + key: "key".to_string(), + content: "body".to_string(), + namespace: Some("ns".to_string()), + category: tc::MemoryCategory::Custom("notes".to_string()), + timestamp: "2026-08-10T00:00:00Z".to_string(), + session_id: Some("s1".to_string()), + score: Some(0.75), + taint: tc::MemoryTaint::ExternalSync, + }; + + let converted = entry_to_tinymemory(engine.clone()); + + assert_eq!(converted.id, engine.id); + assert_eq!(converted.key, engine.key); + assert_eq!(converted.content, engine.content); + assert_eq!(converted.namespace, engine.namespace); + assert_eq!(converted.category.to_string(), engine.category.to_string()); + assert_eq!(converted.timestamp, engine.timestamp); + assert_eq!(converted.session_id, engine.session_id); + assert_eq!(converted.score, engine.score); + assert_eq!(converted.taint, tm::MemoryTaint::ExternalSync); +} + +/// A score of `None` must stay `None`. Defaulting it to `0.0` would make an +/// unranked entry look like a worst-ranked one. +#[test] +fn an_absent_score_stays_absent() { + let engine = tc::MemoryEntry { + id: "i".to_string(), + key: "k".to_string(), + content: "c".to_string(), + namespace: None, + category: tc::MemoryCategory::Core, + timestamp: "t".to_string(), + session_id: None, + score: None, + taint: tc::MemoryTaint::Internal, + }; + let converted = entry_to_tinymemory(engine); + assert!(converted.score.is_none()); + assert!(converted.namespace.is_none()); + assert!(converted.session_id.is_none()); +} + +#[test] +fn a_namespace_summary_round_trips_every_field() { + let engine = tc::NamespaceSummary { + namespace: "projects".to_string(), + count: 12, + last_updated: Some("2026-08-10T00:00:00Z".to_string()), + }; + let converted = namespace_summary_to_tinymemory(engine.clone()); + assert_eq!(converted.namespace, engine.namespace); + assert_eq!(converted.count, engine.count); + assert_eq!(converted.last_updated, engine.last_updated); +} + +/// Every recall filter must cross. A dropped `min_score` or `cross_session` +/// silently widens a query. +#[test] +fn every_recall_filter_crosses() { + let opts = tm::OwnedRecallOpts { + namespace: Some("ns".to_string()), + category: Some(tm::MemoryCategory::Daily), + session_id: Some("s1".to_string()), + min_score: Some(0.5), + cross_session: true, + }; + let engine = recall_opts_to_tinycortex(&opts); + assert_eq!(engine.namespace, opts.namespace); + assert_eq!( + engine.category.as_ref().map(ToString::to_string), + opts.category.as_ref().map(ToString::to_string) + ); + assert_eq!(engine.session_id, opts.session_id); + assert_eq!(engine.min_score, opts.min_score); + assert_eq!(engine.cross_session, opts.cross_session); +} + +#[test] +fn empty_recall_filters_stay_empty() { + let engine = recall_opts_to_tinycortex(&tm::OwnedRecallOpts::default()); + assert!(engine.namespace.is_none()); + assert!(engine.category.is_none()); + assert!(engine.session_id.is_none()); + assert!(engine.min_score.is_none()); + assert!(!engine.cross_session); +} diff --git a/adapters/tinycortex/src/lib.rs b/adapters/tinycortex/src/lib.rs new file mode 100644 index 0000000..078e747 --- /dev/null +++ b/adapters/tinycortex/src/lib.rs @@ -0,0 +1,61 @@ +//! TinyCortex as a TinyMemory driver. +//! +//! This crate is the seam between the TinyCortex engine and the TinyMemory +//! contract. The two describe the same values but are distinct crates, so +//! something has to convert — and it is much better for that to be one small +//! audited crate than a conversion scattered across every call site in a host. +//! +//! ## What is here +//! +//! - [`convert`] — total, exhaustively-destructuring value conversions in both +//! directions. A field added to either contract becomes a compile error here +//! instead of a silently dropped value. +//! - [`TinycortexMemory`] — wraps any TinyCortex [`tinycortex::memory::Memory`] +//! backend as a TinyMemory +//! [`Memory`](tinymemory_api::traits::Memory). +//! - [`provider`] — the one call that turns a TinyCortex backend into a bound +//! driver, by pairing [`TinycortexMemory`] with +//! [`MemoryTraitProvider`]. +//! +//! ## Scope: the mandatory three, not the whole engine +//! +//! A driver built here advertises Core, Recall and Portability. TinyCortex can +//! do far more — trees, chunks, entities, a diff ledger — but those families +//! are reached through engine entry points that need a host's configuration, +//! embedding compute and job queue, none of which this crate has. A host that +//! provides them implements the optional families itself and delegates only the +//! mandatory three here. +//! +//! Advertising only what is reachable is deliberate, not a shortcut: a driver +//! whose capability set overstates its accessors fails +//! [`audit_provider`](tinymemory_api::provider::audit_provider), and a host that +//! filtered its RPC surface from an overstated set would register methods that +//! answer errors. + +pub mod convert; +mod memory; + +pub use memory::TinycortexMemory; + +use std::sync::Arc; + +use tinymemory::mandatory::MemoryTraitProvider; + +/// The driver id this adapter binds under. +/// +/// Matches [`tinymemory::registry::TINYCORTEX_DRIVER_ID`], which is where +/// admission reserves it — the constant lives there so a host that compiles +/// this adapter out still refuses to bind something else under the name. +pub use tinymemory::registry::TINYCORTEX_DRIVER_ID; + +/// Wrap a TinyCortex backend as a bound memory driver. +/// +/// The returned provider advertises the mandatory three families and nothing +/// else; see the crate docs. +#[must_use] +pub fn provider(memory: Arc) -> MemoryTraitProvider { + MemoryTraitProvider::new( + Arc::new(TinycortexMemory::new(memory)), + TINYCORTEX_DRIVER_ID, + ) +} diff --git a/adapters/tinycortex/src/memory.rs b/adapters/tinycortex/src/memory.rs new file mode 100644 index 0000000..02f7533 --- /dev/null +++ b/adapters/tinycortex/src/memory.rs @@ -0,0 +1,181 @@ +//! [`TinycortexMemory`] — a TinyCortex storage backend, seen through the +//! TinyMemory contract's [`Memory`] trait. +//! +//! Every method is a delegation plus a conversion. The two that are not purely +//! mechanical are called out below; both are cases where getting the +//! delegation "obviously right" would be wrong. + +use std::sync::Arc; + +use async_trait::async_trait; +use tinymemory_api::recall::OwnedRecallOpts; +use tinymemory_api::traits::Memory; +use tinymemory_api::types::{ + MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary, RecallOpts, +}; + +use crate::convert::{ + category_to_tinycortex, entry_to_tinymemory, namespace_summary_to_tinymemory, + recall_opts_to_tinycortex, taint_to_tinycortex, +}; + +/// A TinyCortex backend exposed as a TinyMemory [`Memory`]. +pub struct TinycortexMemory { + inner: Arc, +} + +impl std::fmt::Debug for TinycortexMemory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // The backend is not `Debug` and may hold a path or a connection + // string; its name is the part that is safe to render. + f.debug_struct("TinycortexMemory") + .field("backend", &self.inner.name()) + .finish() + } +} + +impl TinycortexMemory { + /// Wrap a TinyCortex backend. + #[must_use] + pub fn new(inner: Arc) -> Self { + Self { inner } + } + + /// The wrapped backend, for a caller that still needs engine-native access. + #[must_use] + pub fn inner(&self) -> &Arc { + &self.inner + } +} + +#[async_trait] +impl Memory for TinycortexMemory { + fn name(&self) -> &str { + self.inner.name() + } + + async fn store( + &self, + namespace: &str, + key: &str, + content: &str, + category: MemoryCategory, + session_id: Option<&str>, + ) -> anyhow::Result<()> { + self.inner + .store( + namespace, + key, + content, + category_to_tinycortex(category), + session_id, + ) + .await + } + + /// **Overridden, and it must stay overridden.** The trait default degrades + /// to [`Memory::store`], which drops the taint — so a backend reached + /// through the default would launder externally-sourced content into + /// internal-trust content. Forwarding to the engine's own + /// `store_with_taint` keeps provenance intact end to end. + async fn store_with_taint( + &self, + namespace: &str, + key: &str, + content: &str, + category: MemoryCategory, + session_id: Option<&str>, + taint: MemoryTaint, + ) -> anyhow::Result<()> { + self.inner + .store_with_taint( + namespace, + key, + content, + category_to_tinycortex(category), + session_id, + taint_to_tinycortex(taint), + ) + .await + } + + /// The engine's `RecallOpts` borrows its string fields, so the owned + /// conversion has to outlive the borrow taken from it — hence the local + /// binding rather than a temporary in the call. + async fn recall( + &self, + query: &str, + limit: usize, + opts: RecallOpts<'_>, + ) -> anyhow::Result> { + let owned = OwnedRecallOpts::from(opts); + let engine_owned = recall_opts_to_tinycortex(&owned); + let hits = self + .inner + .recall(query, limit, (&engine_owned).into()) + .await?; + Ok(hits.into_iter().map(entry_to_tinymemory).collect()) + } + + async fn recall_relevant_by_vector( + &self, + namespace: &str, + query: &str, + limit: usize, + min_vector_similarity: f64, + ) -> anyhow::Result> { + self.inner + .recall_relevant_by_vector(namespace, query, limit, min_vector_similarity) + .await + } + + async fn get(&self, namespace: &str, key: &str) -> anyhow::Result> { + Ok(self + .inner + .get(namespace, key) + .await? + .map(entry_to_tinymemory)) + } + + async fn list( + &self, + namespace: Option<&str>, + category: Option<&MemoryCategory>, + session_id: Option<&str>, + ) -> anyhow::Result> { + // `category` is borrowed on both sides, so the converted value needs a + // binding to borrow from. + let engine_category = category.cloned().map(category_to_tinycortex); + let entries = self + .inner + .list(namespace, engine_category.as_ref(), session_id) + .await?; + Ok(entries.into_iter().map(entry_to_tinymemory).collect()) + } + + async fn forget(&self, namespace: &str, key: &str) -> anyhow::Result { + self.inner.forget(namespace, key).await + } + + async fn namespace_summaries(&self) -> anyhow::Result> { + Ok(self + .inner + .namespace_summaries() + .await? + .into_iter() + .map(namespace_summary_to_tinymemory) + .collect()) + } + + async fn count(&self) -> anyhow::Result { + self.inner.count().await + } + + async fn health_check(&self) -> bool { + self.inner.health_check().await + } +} + +#[cfg(test)] +#[path = "memory_test.rs"] +mod test; diff --git a/adapters/tinycortex/src/memory_test.rs b/adapters/tinycortex/src/memory_test.rs new file mode 100644 index 0000000..c618d12 --- /dev/null +++ b/adapters/tinycortex/src/memory_test.rs @@ -0,0 +1,231 @@ +//! End-to-end tests for the seam, against a real TinyCortex backend. +//! +//! These are the ones that would catch a conversion that type-checks but means +//! the wrong thing, because everything here goes in through the TinyMemory +//! contract and comes back out of the engine's own store. + +#![allow(clippy::expect_used, clippy::panic)] + +use tinycortex::memory::store::InMemoryMemoryStore; +use tinymemory_api::provider::{audit_provider, MemoryCore, MemoryPortability, MemoryProvider}; +use tinymemory_api::types::{MemoryCategory, MemoryTaint, GLOBAL_NAMESPACE}; + +use super::*; +use crate::TINYCORTEX_DRIVER_ID; + +fn engine() -> Arc { + Arc::new(InMemoryMemoryStore::new()) +} + +#[tokio::test] +async fn the_adapter_reports_the_engine_backend_name() { + let memory = TinycortexMemory::new(engine()); + assert_eq!(memory.name(), "in_memory"); +} + +#[tokio::test] +async fn a_driver_over_the_engine_advertises_exactly_the_mandatory_three() { + let driver = crate::provider(engine()); + audit_provider(&driver).expect("advertised capabilities match the accessors"); + assert_eq!(driver.driver_id(), TINYCORTEX_DRIVER_ID); +} + +#[tokio::test] +async fn store_and_get_round_trip_through_the_contract() { + let driver = crate::provider(engine()); + driver + .store( + "projects", + "k", + "body", + MemoryCategory::Daily, + Some("s1"), + MemoryTaint::Internal, + ) + .await + .expect("store"); + + let entry = driver + .get("projects", "k") + .await + .expect("get") + .expect("present"); + assert_eq!(entry.content, "body"); + assert_eq!(entry.category, MemoryCategory::Daily); + assert_eq!(entry.session_id.as_deref(), Some("s1")); + assert_eq!(entry.taint, MemoryTaint::Internal); +} + +/// The end-to-end version of the taint check: content stored as external +/// through the contract must still read back as external from the engine. +#[tokio::test] +async fn provenance_survives_the_seam_in_both_directions() { + let driver = crate::provider(engine()); + driver + .store( + "ns", + "synced", + "from gmail", + MemoryCategory::Core, + None, + MemoryTaint::ExternalSync, + ) + .await + .expect("store"); + + let entry = driver + .get("ns", "synced") + .await + .expect("get") + .expect("present"); + assert_eq!( + entry.taint, + MemoryTaint::ExternalSync, + "external content must not be laundered into internal trust" + ); +} + +/// The shared `list(None, ..)` fix, verified against a real engine rather than +/// a test double. +#[tokio::test] +async fn listing_with_no_namespace_spans_every_namespace() { + let driver = crate::provider(engine()); + for (namespace, key) in [(GLOBAL_NAMESPACE, "a"), ("projects", "b"), ("people", "c")] { + driver + .store( + namespace, + key, + "body", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .expect("store"); + } + + let everything = driver.list(None, None, None).await.expect("list"); + assert_eq!(everything.len(), 3); + + let scoped = driver + .list(Some("projects"), None, None) + .await + .expect("list"); + assert_eq!(scoped.len(), 1); +} + +#[tokio::test] +async fn forget_reports_whether_the_entry_existed() { + let driver = crate::provider(engine()); + driver + .store( + "ns", + "k", + "v", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .expect("store"); + assert!(driver.forget("ns", "k").await.expect("forget")); + assert!(!driver.forget("ns", "k").await.expect("forget again")); +} + +#[tokio::test] +async fn namespaces_reports_the_engine_summaries() { + let driver = crate::provider(engine()); + driver + .store( + "projects", + "k", + "v", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .expect("store"); + let namespaces = driver.namespaces().await.expect("namespaces"); + assert_eq!(namespaces.len(), 1); + assert_eq!(namespaces[0].namespace, "projects"); + assert_eq!(namespaces[0].count, 1); +} + +/// The acceptance property for the whole seam: a store can be exported through +/// the contract and restored into a second engine with provenance intact. +#[tokio::test] +async fn a_store_exports_and_restores_across_two_engines() { + let source = crate::provider(engine()); + source + .store( + "ns", + "internal", + "typed by the user", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .expect("store"); + source + .store( + "ns", + "external", + "from a sync", + MemoryCategory::Daily, + Some("s1"), + MemoryTaint::ExternalSync, + ) + .await + .expect("store"); + + let mut records = Vec::new(); + let mut cursor = None; + let mut pages = 0; + loop { + let page = source + .export_page(cursor.as_deref(), 1) + .await + .expect("export page"); + pages += 1; + assert!(pages < 10, "export did not terminate"); + records.extend(page.records); + match page.next_cursor { + Some(next) => cursor = Some(next), + None => break, + } + } + assert_eq!(records.len(), 2); + + let target = crate::provider(engine()); + let outcome = target.import_records(records).await.expect("import"); + assert_eq!(outcome.imported, 2); + assert_eq!(outcome.failed, 0); + + let external = target + .get("ns", "external") + .await + .expect("get") + .expect("present"); + assert_eq!(external.taint, MemoryTaint::ExternalSync); + assert_eq!(external.content, "from a sync"); + assert_eq!(external.session_id.as_deref(), Some("s1")); + assert_eq!(external.category, MemoryCategory::Daily); + + let internal = target + .get("ns", "internal") + .await + .expect("get") + .expect("present"); + assert_eq!(internal.taint, MemoryTaint::Internal); + assert_eq!(internal.category, MemoryCategory::Core); +} + +/// A driver id is rendered into logs and audit events; the backend handle may +/// hold a path or connection string and must not be. +#[test] +fn debug_renders_the_backend_name_and_not_the_handle() { + let rendered = format!("{:?}", TinycortexMemory::new(engine())); + assert!(rendered.contains("in_memory")); +} diff --git a/api/Cargo.toml b/api/Cargo.toml new file mode 100644 index 0000000..4566eb5 --- /dev/null +++ b/api/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "tinymemory-api" +version = "0.1.1" +edition = "2021" +license = "MIT" +repository = "https://github.com/tinyhumansai/tinymemory" +description = "Stable public contracts for the TinyMemory memory system" + +# Deliberately dependency-light: this crate is the stable contract surface that +# hosts compile against, so it must stay free of native, async-runtime, and +# storage dependencies. Anything heavier belongs in the `tinycortex` engine +# crate, never here. +# +# The full set is intentionally small and pure-Rust. Beyond the +# serde/error/async-trait baseline it carries exactly three additions, each +# pulled in by a value type that has to keep behaving identically after the +# move out of the engine crate: +# +# - `chrono` — timestamps on chunk/tree nodes; the `serde` feature backs +# `chunks::Metadata`'s `chrono::serde::ts_milliseconds`. +# - `sha2` — the deterministic `chunks::chunk_id`. +# - `uuid` — `tool_memory::ToolMemoryRule::generate_id` (v4 bytes, nibble +# encoded). Only the `v4` feature is needed here; the engine +# crate additionally enables `serde`. +# +# Nothing here may pull in `rusqlite`, `git2`, `reqwest`, `regex`, or an async +# runtime. Guard with the FORWARD form, which is scoped to this package: +# +# cargo tree -p tinymemory-api -e normal,build --prefix none \ +# | grep -Ei 'rusqlite|libsqlite|git2|reqwest|regex|tokio' # expect no match +# +# Do NOT use `cargo tree -i -p tinymemory-api`: `-i` discards the `-p` +# scope and prints the whole-workspace inverse tree, so it exits 0 and looks +# clean even when this crate is the one pulling the dependency in. +[dependencies] +anyhow = "1" +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" +uuid = { version = "1", features = ["v4"] } diff --git a/api/src/capabilities.rs b/api/src/capabilities.rs new file mode 100644 index 0000000..7c1e7d3 --- /dev/null +++ b/api/src/capabilities.rs @@ -0,0 +1,397 @@ +//! Capability families a memory driver may advertise, and the set type used to +//! negotiate them. +//! +//! ## Why capabilities exist +//! +//! A memory driver is not required to implement the whole surface. The kernel +//! asks a driver which families it supports **once**, at bind time, caches the +//! answer, and then unregisters the RPC methods and omits the agent tools that +//! belong to an unadvertised family. Absence beats a registered handler that +//! returns "not implemented": a present-but-failing method teaches a model that +//! the capability exists and makes it retry. +//! +//! Calling an unadvertised capability is therefore a *kernel* bug, not a driver +//! error. [`crate::error::MemoryError::Unsupported`] exists for the one case the +//! kernel cannot pre-empt: an out-of-process driver that answers `501` for a +//! family its handshake claimed. +//! +//! ## Mandatory families +//! +//! [`Capability::Core`], [`Capability::Recall`], and [`Capability::Portability`] +//! are mandatory. Without core and recall a driver is not a memory backend at +//! all; without portability a user cannot leave it, which makes the binding a +//! one-way door. [`Capabilities::validate`] is the single place that rule is +//! encoded — call it at bind time and refuse the bind on `Err`. +//! +//! ## Wire stability +//! +//! The set crosses the process boundary in the driver handshake +//! (`POST /v1/handshake` → `{ contract_version, driver_id, capabilities[] }`), +//! so the serialized form is a JSON **array of stable snake_case strings**, not +//! discriminant integers — inserting a variant in the middle of the enum must +//! not silently re-map an already-deployed driver's advertised set. +//! [`Capability::as_str`] is the authority for those strings and is pinned +//! against the serde derive by a test. +//! +//! ## Deliberately not `#[non_exhaustive]` +//! +//! Adding a family is a [`crate::CONTRACT_VERSION`] **minor** bump and should +//! break every exhaustive `match` in every host that filters registration by +//! family — that compile error is the mechanism which guarantees the new family +//! is actually wired somewhere. Marking this enum `#[non_exhaustive]` would +//! convert that compile-time guarantee into a silent fall-through at the crate +//! boundary (the failure mode recorded for `DataSource` during the M0 +//! carve-out). If a future family must be added without breaking downstream +//! matches, bump the **major** version instead. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::error::MemoryError; + +/// One capability family a memory driver may advertise. +/// +/// The variants are exactly the thirteen families of the memory contract. Each +/// maps to a trait family in the contract, a group of RPC methods, and a group +/// of agent tools; a driver that does not advertise a family simply has that +/// surface absent. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Capability { + /// Store / get / forget / list / namespaces. **Mandatory.** + Core, + /// Ranked retrieval for a query. **Mandatory.** + Recall, + /// Document and chat ingestion — the driver owns chunking and embedding. + Ingest, + /// The namespace-document tier: put / get / query documents. + Documents, + /// Summary-tree query, drill-down, seal, and cascade. + Tree, + /// Entity index, entity edges, and hotness. + Entities, + /// Key/value graph read and write. + Graph, + /// Snapshot capture and change computation. + Diff, + /// Goal extraction and goal records. + Goals, + /// Per-tool learned memory. + ToolMemory, + /// Accepting synced source items; the host still owns credentials and + /// scheduling. + Sources, + /// Re-embed, compact, consolidate ("dream"), and doctor. + Maintenance, + /// Export and import of the whole store as a stream. **Mandatory.** + Portability, +} + +impl Capability { + /// Every family, in declaration order. + /// + /// Declaration order is also bit order in [`Capabilities`] and iteration + /// order in its serialized form, so this slice is the single ordering + /// authority for the whole module. + pub const ALL: [Capability; 13] = [ + Capability::Core, + Capability::Recall, + Capability::Ingest, + Capability::Documents, + Capability::Tree, + Capability::Entities, + Capability::Graph, + Capability::Diff, + Capability::Goals, + Capability::ToolMemory, + Capability::Sources, + Capability::Maintenance, + Capability::Portability, + ]; + + /// The families a driver must advertise to be bindable at all. + /// + /// See the module docs for why these three and not others. + pub const MANDATORY: [Capability; 3] = [ + Capability::Core, + Capability::Recall, + Capability::Portability, + ]; + + /// Every family, in declaration order. Slice form of [`Self::ALL`], for + /// callers that want to iterate without naming the array length. + pub fn all() -> &'static [Capability] { + &Self::ALL + } + + /// Stable snake_case identifier used on the wire, in config, and in logs. + /// + /// This is the authority for the serialized form; the serde derive is + /// pinned against it by `capability_as_str_matches_serde_representation`. + /// Changing a string here is a breaking change for every already-deployed + /// driver and requires a [`crate::CONTRACT_VERSION`] major bump. + pub fn as_str(self) -> &'static str { + match self { + Self::Core => "core", + Self::Recall => "recall", + Self::Ingest => "ingest", + Self::Documents => "documents", + Self::Tree => "tree", + Self::Entities => "entities", + Self::Graph => "graph", + Self::Diff => "diff", + Self::Goals => "goals", + Self::ToolMemory => "tool_memory", + Self::Sources => "sources", + Self::Maintenance => "maintenance", + Self::Portability => "portability", + } + } + + /// Parse back from the on-wire form. + /// + /// # Errors + /// + /// Returns the unrecognised input in an error message. An unknown string is + /// expected in practice: a driver speaking a newer minor contract version + /// may advertise a family this build has never heard of. Callers + /// negotiating a handshake should **skip** unknown families rather than + /// fail the bind — an unknown family is one this kernel would never call. + pub fn parse(raw: &str) -> Result { + Self::ALL + .iter() + .copied() + .find(|cap| cap.as_str() == raw) + .ok_or_else(|| format!("unknown memory capability: {raw}")) + } + + /// Whether this family is mandatory for every driver. + pub fn is_mandatory(self) -> bool { + Self::MANDATORY.contains(&self) + } + + /// Position of this family in [`Self::ALL`]; also its bit index in + /// [`Capabilities`]. + fn index(self) -> u16 { + match self { + Self::Core => 0, + Self::Recall => 1, + Self::Ingest => 2, + Self::Documents => 3, + Self::Tree => 4, + Self::Entities => 5, + Self::Graph => 6, + Self::Diff => 7, + Self::Goals => 8, + Self::ToolMemory => 9, + Self::Sources => 10, + Self::Maintenance => 11, + Self::Portability => 12, + } + } + + /// Single-bit mask for this family within a [`Capabilities`] set. + fn bit(self) -> u64 { + 1u64 << self.index() + } +} + +impl std::fmt::Display for Capability { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for Capability { + type Err = String; + + fn from_str(raw: &str) -> Result { + Self::parse(raw) + } +} + +/// A driver's advertised capability set. +/// +/// Internally a bitset, so `contains` is a single mask test on the hot path and +/// the type is `Copy`. Externally it serializes as a JSON array of +/// [`Capability::as_str`] strings in [`Capability::ALL`] order — duplicates in +/// the input collapse, and ordering in the input is not preserved, because a +/// set has neither. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Capabilities { + bits: u64, +} + +impl Capabilities { + /// The empty default capability set. The `null` driver advertises + /// [`Self::mandatory`] via its [`MemoryProvider::capabilities`](crate::provider::MemoryProvider::capabilities) + /// implementation, not this. + pub const fn empty() -> Self { + Self { bits: 0 } + } + + /// Every family. Advertised by the embedded `tinycortex` driver. + pub fn all() -> Self { + Capability::ALL.into_iter().collect() + } + + /// Exactly the mandatory families — the minimum bindable set. + pub fn mandatory() -> Self { + Capability::MANDATORY.into_iter().collect() + } + + /// Whether `capability` is advertised. + pub fn contains(&self, capability: Capability) -> bool { + self.bits & capability.bit() != 0 + } + + /// Whether every family in `other` is advertised here. + pub fn contains_all(&self, other: Capabilities) -> bool { + self.bits & other.bits == other.bits + } + + /// Adds `capability` in place. Idempotent. + pub fn insert(&mut self, capability: Capability) { + self.bits |= capability.bit(); + } + + /// Removes `capability` in place. Idempotent. + pub fn remove(&mut self, capability: Capability) { + self.bits &= !capability.bit(); + } + + /// Builder form of [`Self::insert`]. + pub fn with(mut self, capability: Capability) -> Self { + self.insert(capability); + self + } + + /// Builder form of [`Self::remove`]. + pub fn without(mut self, capability: Capability) -> Self { + self.remove(capability); + self + } + + /// Advertised families in [`Capability::ALL`] order. + pub fn iter(&self) -> impl Iterator + '_ { + Capability::ALL + .into_iter() + .filter(move |cap| self.contains(*cap)) + } + + /// Number of advertised families. + pub fn len(&self) -> usize { + self.bits.count_ones() as usize + } + + /// Whether no family is advertised. + pub fn is_empty(&self) -> bool { + self.bits == 0 + } + + /// Mandatory families this set is missing, in [`Capability::ALL`] order. + /// Empty when the set is bindable. + pub fn missing_mandatory(&self) -> Vec { + Capability::MANDATORY + .into_iter() + .filter(|cap| !self.contains(*cap)) + .collect() + } + + /// Rejects a set that is missing any mandatory family. + /// + /// Call this at bind time; on `Err` refuse the bind and fall back to the + /// embedded default rather than binding a driver a user could not leave. + /// + /// # Errors + /// + /// Returns [`MissingMandatoryCapabilities`] listing **every** missing + /// mandatory family, not just the first, so the operator sees the whole gap + /// in one message. + pub fn validate(&self) -> Result<(), MissingMandatoryCapabilities> { + let missing = self.missing_mandatory(); + if missing.is_empty() { + Ok(()) + } else { + Err(MissingMandatoryCapabilities { missing }) + } + } +} + +impl FromIterator for Capabilities { + fn from_iter>(iter: I) -> Self { + let mut set = Self::empty(); + for capability in iter { + set.insert(capability); + } + set + } +} + +impl Extend for Capabilities { + fn extend>(&mut self, iter: I) { + for capability in iter { + self.insert(capability); + } + } +} + +impl Serialize for Capabilities { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_seq(self.iter()) + } +} + +impl<'de> Deserialize<'de> for Capabilities { + /// Skips any family string this build does not recognise, rather than + /// failing the whole deserialize. + /// + /// A remote driver speaking a newer minor contract version may advertise a + /// family this build has never heard of — see [`Capability::parse`] and the + /// module-level "wire stability" docs. Rejecting the whole handshake on one + /// unknown string would refuse an otherwise-compatible driver; the correct + /// behaviour is to drop the family this kernel could never call anyway. + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = Vec::::deserialize(deserializer)?; + let families = raw + .into_iter() + .filter_map(|family| Capability::parse(&family).ok()); + Ok(families.collect()) + } +} + +/// A driver advertised a capability set missing at least one mandatory family. +/// +/// Carries the missing families rather than a formatted string so the caller +/// can report them structurally (status RPC, bind-failure event) as well as in +/// a log line. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error( + "memory driver advertises an incomplete capability set; missing mandatory families: {}", + .missing.iter().map(|c| c.as_str()).collect::>().join(", ") +)] +pub struct MissingMandatoryCapabilities { + /// Mandatory families absent from the advertised set, in + /// [`Capability::ALL`] order. Never empty. + pub missing: Vec, +} + +impl From for MemoryError { + /// An incomplete advertised set is a caller/config error, not an + /// unsupported call: the driver said something invalid about itself, which + /// is why this maps to [`MemoryError::Invalid`] and not + /// [`MemoryError::Unsupported`]. + fn from(value: MissingMandatoryCapabilities) -> Self { + MemoryError::Invalid(value.to_string()) + } +} + +#[cfg(test)] +#[path = "capabilities_tests.rs"] +mod tests; diff --git a/api/src/capabilities_tests.rs b/api/src/capabilities_tests.rs new file mode 100644 index 0000000..1e5e550 --- /dev/null +++ b/api/src/capabilities_tests.rs @@ -0,0 +1,320 @@ +//! Unit tests for the capability vocabulary in [`super`]. +//! +//! Three properties are load-bearing and each has its own test: +//! +//! 1. the enum has exactly the thirteen contract families and no more; +//! 2. the serialized form is stable snake_case **strings**, never discriminant +//! integers — a driver deployed against an older build must keep advertising +//! the same set after a variant is inserted mid-enum; +//! 3. [`super::Capabilities::validate`] rejects a set missing **any** of the +//! three mandatory families, checked one family at a time. + +use super::*; +use serde_json::json; + +#[test] +fn capability_has_exactly_the_thirteen_contract_families() { + assert_eq!(Capability::ALL.len(), 13); + assert_eq!(Capability::all().len(), 13); + + let names: Vec<&str> = Capability::ALL.iter().map(|c| c.as_str()).collect(); + assert_eq!( + names, + vec![ + "core", + "recall", + "ingest", + "documents", + "tree", + "entities", + "graph", + "diff", + "goals", + "tool_memory", + "sources", + "maintenance", + "portability", + ] + ); +} + +#[test] +fn capability_all_has_no_duplicates() { + let mut seen = std::collections::BTreeSet::new(); + for capability in Capability::ALL { + assert!( + seen.insert(capability.as_str()), + "duplicate capability in ALL: {capability}" + ); + } +} + +#[test] +fn capability_as_str_matches_serde_representation() { + // The wire form is the stable contract; `as_str` is the authority and the + // derive must agree with it for every variant. + for capability in Capability::ALL { + assert_eq!( + serde_json::to_value(capability).unwrap(), + json!(capability.as_str()), + "serde form drifted from as_str for {capability}" + ); + } +} + +#[test] +fn capability_serializes_as_a_string_not_an_integer() { + // Guards the specific regression the string form exists to prevent: + // inserting a variant must not re-map an already-deployed driver's set. + for capability in Capability::ALL { + assert!( + serde_json::to_value(capability).unwrap().is_string(), + "{capability} did not serialize as a string" + ); + } +} + +#[test] +fn capability_parse_round_trips_every_variant() { + for capability in Capability::ALL { + assert_eq!(Capability::parse(capability.as_str()), Ok(capability)); + assert_eq!( + capability.as_str().parse::(), + Ok(capability), + "FromStr disagreed with parse for {capability}" + ); + let decoded: Capability = + serde_json::from_value(json!(capability.as_str())).expect("known family decodes"); + assert_eq!(decoded, capability); + } +} + +#[test] +fn capability_parse_rejects_unknown_family() { + let err = Capability::parse("quantum_recall").expect_err("unknown family must not parse"); + assert!(err.contains("quantum_recall"), "unhelpful error: {err}"); +} + +#[test] +fn mandatory_families_are_core_recall_and_portability() { + assert_eq!( + Capability::MANDATORY, + [ + Capability::Core, + Capability::Recall, + Capability::Portability + ] + ); + for capability in Capability::ALL { + assert_eq!( + capability.is_mandatory(), + matches!( + capability, + Capability::Core | Capability::Recall | Capability::Portability + ), + "wrong mandatory classification for {capability}" + ); + } +} + +#[test] +fn capabilities_all_contains_every_family() { + let all = Capabilities::all(); + assert_eq!(all.len(), Capability::ALL.len()); + for capability in Capability::ALL { + assert!(all.contains(capability), "all() is missing {capability}"); + } + assert!(!all.is_empty()); +} + +#[test] +fn capabilities_empty_contains_nothing() { + let none = Capabilities::empty(); + assert!(none.is_empty()); + assert_eq!(none.len(), 0); + for capability in Capability::ALL { + assert!(!none.contains(capability)); + } + // The default capability set is empty (the null driver itself advertises + // `Capabilities::mandatory()`, not the default). + assert_eq!(Capabilities::default(), none); +} + +#[test] +fn capabilities_bit_width_has_room_well_beyond_the_current_thirteen_families() { + // A `u16` bitset (the original representation) has exactly 16 bit + // positions, leaving room for only 3 more families before a family's + // `1 << index` bit-shift overflows. Pin the wider `u64` representation so + // a future family addition doesn't have to rediscover that ceiling. + assert!(std::mem::size_of::() * 8 >= 64); +} + +#[test] +fn capabilities_insert_and_remove_are_idempotent() { + let mut set = Capabilities::empty(); + set.insert(Capability::Tree); + set.insert(Capability::Tree); + assert_eq!(set.len(), 1); + assert!(set.contains(Capability::Tree)); + assert!(!set.contains(Capability::Graph)); + + set.remove(Capability::Tree); + set.remove(Capability::Tree); + assert!(set.is_empty()); +} + +#[test] +fn capabilities_builder_forms_mirror_insert_and_remove() { + let set = Capabilities::empty() + .with(Capability::Core) + .with(Capability::Recall) + .without(Capability::Recall); + assert!(set.contains(Capability::Core)); + assert!(!set.contains(Capability::Recall)); +} + +#[test] +fn capabilities_contains_all_checks_subsets() { + let full = Capabilities::all(); + let mandatory = Capabilities::mandatory(); + + assert!(full.contains_all(mandatory)); + assert!(!mandatory.contains_all(full)); + assert!(mandatory.contains_all(mandatory)); + assert!(full.contains_all(Capabilities::empty())); +} + +#[test] +fn capabilities_iterates_in_declaration_order() { + let set: Capabilities = [ + Capability::Portability, + Capability::Core, + Capability::Tree, + Capability::Recall, + ] + .into_iter() + .collect(); + + assert_eq!( + set.iter().collect::>(), + vec![ + Capability::Core, + Capability::Recall, + Capability::Tree, + Capability::Portability + ] + ); +} + +#[test] +fn capabilities_serde_round_trips_and_uses_a_string_array() { + let set = Capabilities::mandatory().with(Capability::ToolMemory); + let encoded = serde_json::to_value(set).unwrap(); + + // Declaration order, snake_case strings — the `capabilities[]` handshake + // field. + assert_eq!( + encoded, + json!(["core", "recall", "tool_memory", "portability"]) + ); + + let decoded: Capabilities = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, set); +} + +#[test] +fn capabilities_full_set_serde_round_trips() { + let all = Capabilities::all(); + let encoded = serde_json::to_string(&all).unwrap(); + let decoded: Capabilities = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded, all); +} + +#[test] +fn capabilities_deserialization_collapses_duplicates_and_ignores_order() { + let decoded: Capabilities = + serde_json::from_value(json!(["portability", "core", "core", "recall"])).unwrap(); + assert_eq!(decoded, Capabilities::mandatory()); + assert_eq!(decoded.len(), 3); +} + +#[test] +fn capabilities_deserialization_skips_an_unknown_family() { + // A remote driver speaking a newer minor contract version may advertise a + // family this build has never heard of (see the module docs' "wire + // stability" section and `Capability::parse`). The handshake must still + // decode — with the unknown family dropped — rather than failing the bind + // outright. + let decoded: Capabilities = + serde_json::from_value(json!(["core", "warp_drive", "recall"])).unwrap(); + assert_eq!( + decoded, + Capabilities::empty() + .with(Capability::Core) + .with(Capability::Recall) + ); +} + +#[test] +fn validate_accepts_the_minimum_bindable_set() { + assert_eq!(Capabilities::mandatory().validate(), Ok(())); + assert_eq!(Capabilities::all().validate(), Ok(())); +} + +#[test] +fn validate_rejects_a_set_missing_core() { + let set = Capabilities::all().without(Capability::Core); + let err = set.validate().expect_err("missing core must be rejected"); + assert_eq!(err.missing, vec![Capability::Core]); + assert!(err.to_string().contains("core"), "{err}"); +} + +#[test] +fn validate_rejects_a_set_missing_recall() { + let set = Capabilities::all().without(Capability::Recall); + let err = set.validate().expect_err("missing recall must be rejected"); + assert_eq!(err.missing, vec![Capability::Recall]); + assert!(err.to_string().contains("recall"), "{err}"); +} + +#[test] +fn validate_rejects_a_set_missing_portability() { + // Portability is mandatory because without it a bind is a one-way door. + let set = Capabilities::all().without(Capability::Portability); + let err = set + .validate() + .expect_err("missing portability must be rejected"); + assert_eq!(err.missing, vec![Capability::Portability]); + assert!(err.to_string().contains("portability"), "{err}"); +} + +#[test] +fn validate_reports_every_missing_mandatory_family_at_once() { + let err = Capabilities::empty() + .validate() + .expect_err("the null set must be rejected"); + assert_eq!( + err.missing, + vec![ + Capability::Core, + Capability::Recall, + Capability::Portability + ] + ); +} + +#[test] +fn missing_mandatory_converts_to_an_invalid_memory_error() { + let err = Capabilities::empty().validate().unwrap_err(); + let message = err.to_string(); + let converted: MemoryError = err.into(); + // An incomplete advertised set is a bad claim about the driver, not an + // unsupported call. + assert!(matches!(converted, MemoryError::Invalid(ref m) if *m == message)); +} + +#[test] +fn missing_mandatory_is_empty_for_a_valid_set() { + assert!(Capabilities::mandatory().missing_mandatory().is_empty()); + assert!(Capabilities::all().missing_mandatory().is_empty()); +} diff --git a/api/src/chunks.rs b/api/src/chunks.rs new file mode 100644 index 0000000..789bc1b --- /dev/null +++ b/api/src/chunks.rs @@ -0,0 +1,424 @@ +//! Core types for the memory chunk layer. +//! +//! This module defines the canonical [`Chunk`] representation produced by the +//! ingestion pipeline along with its provenance [`Metadata`] and back-pointer +//! [`SourceRef`]. +//! +//! All chunk IDs are deterministic: `sha256(source_kind | "\0" | source_id | +//! "\0" | seq | "\0" | content)` truncated to 32 hex chars so re-ingest of the +//! same source material yields stable IDs and idempotent upserts. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Which kind of upstream source produced a chunk. +/// +/// Used both as a metadata discriminator and as the routing key for the +/// canonicaliser dispatch in the ingest pipeline. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceKind { + /// Chat transcript scoped by channel or group (Slack, Discord, Telegram, WhatsApp…). + Chat, + /// Email thread (Gmail and generic IMAP). + Email, + /// Standalone document (Notion page, Drive doc, meeting note, uploaded file…). + Document, +} + +impl SourceKind { + /// Stable string representation for DB storage and RPC surfaces. + pub fn as_str(self) -> &'static str { + match self { + SourceKind::Chat => "chat", + SourceKind::Email => "email", + SourceKind::Document => "document", + } + } + + /// Parse back from the on-wire / on-disk string form. + /// + /// # Errors + /// + /// Returns an error when `s` is not a supported source kind. + pub fn parse(s: &str) -> Result { + match s { + "chat" => Ok(SourceKind::Chat), + "email" => Ok(SourceKind::Email), + "document" => Ok(SourceKind::Document), + other => Err(format!("unknown source kind: {other}")), + } + } +} + +/// Concrete upstream provider the content came from. +/// +/// Each variant maps to exactly one [`SourceKind`] via [`Self::kind`]. Wire +/// form is snake_case (see [`Self::as_str`] / [`Self::parse`]) so it is stable +/// across DB rows, JSON-RPC payloads, and logs. +/// +/// Marked `#[non_exhaustive]` so new providers can be added in later phases +/// without breaking downstream pattern matches. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum DataSource { + // ── Chat transcripts (grouped by channel/group) ──────────────────── + /// Discord channel/server messages. Feeds [`SourceKind::Chat`]. + Discord, + /// Telegram chat/group messages. Feeds [`SourceKind::Chat`]. + Telegram, + /// WhatsApp chat/group messages. Feeds [`SourceKind::Chat`]. + Whatsapp, + + // ── Agent conversations (stored as durable memory) ──────────────── + /// Agent conversation transcripts persisted as durable memory. Feeds [`SourceKind::Chat`]. + Conversation, + + // ── Email threads (grouped by thread) ────────────────────────────── + /// Gmail thread. Feeds [`SourceKind::Email`]. + Gmail, + /// Catch-all for non-Gmail providers (Outlook, FastMail, generic IMAP, …). + OtherEmail, + + // ── Documents (no grouping) ──────────────────────────────────────── + /// Notion page. Feeds [`SourceKind::Document`]. + Notion, + /// Meeting notes document. Feeds [`SourceKind::Document`]. + MeetingNotes, + /// Google Drive document. Feeds [`SourceKind::Document`]. + DriveDocs, +} + +impl DataSource { + /// Which [`SourceKind`] this provider feeds into. + pub fn kind(self) -> SourceKind { + match self { + Self::Discord | Self::Telegram | Self::Whatsapp | Self::Conversation => { + SourceKind::Chat + } + Self::Gmail | Self::OtherEmail => SourceKind::Email, + Self::Notion | Self::MeetingNotes | Self::DriveDocs => SourceKind::Document, + } + } + + /// Stable snake_case identifier for DB storage, RPC payloads, and logs. + pub fn as_str(self) -> &'static str { + match self { + Self::Discord => "discord", + Self::Telegram => "telegram", + Self::Whatsapp => "whatsapp", + Self::Conversation => "conversation", + Self::Gmail => "gmail", + Self::OtherEmail => "other_email", + Self::Notion => "notion", + Self::MeetingNotes => "meeting_notes", + Self::DriveDocs => "drive_docs", + } + } + + /// Parse back from the on-wire / on-disk string form. + /// + /// # Errors + /// + /// Returns an error when `s` is not a supported data source. + pub fn parse(s: &str) -> Result { + match s { + "discord" => Ok(Self::Discord), + "telegram" => Ok(Self::Telegram), + "whatsapp" => Ok(Self::Whatsapp), + "conversation" => Ok(Self::Conversation), + "gmail" => Ok(Self::Gmail), + "other_email" => Ok(Self::OtherEmail), + "notion" => Ok(Self::Notion), + "meeting_notes" => Ok(Self::MeetingNotes), + "drive_docs" => Ok(Self::DriveDocs), + other => Err(format!("unknown data source: {other}")), + } + } + + /// Every known variant, in declaration order. Useful for tests, CLI + /// completion, and enumerating supported providers in diagnostic output. + pub fn all() -> &'static [DataSource] { + &[ + Self::Discord, + Self::Telegram, + Self::Whatsapp, + Self::Conversation, + Self::Gmail, + Self::OtherEmail, + Self::Notion, + Self::MeetingNotes, + Self::DriveDocs, + ] + } +} + +/// A concrete pointer back to where a chunk originated — used for citation, +/// drill-down, and deduplication at re-ingest time. +/// +/// Consumers should treat this as an opaque, source-specific reference. The +/// shape depends on [`SourceKind`]: +/// - **Chat**: `{platform}://{channel}/{message_id}` or `{permalink}` +/// - **Email**: message-id header (``) or provider URL +/// - **Document**: file path, Notion page URL, Drive file id +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct SourceRef { + /// Opaque provider-specific identifier for the exact source record. + pub value: String, +} + +impl SourceRef { + /// Wrap an opaque provider-specific identifier as a [`SourceRef`]. + pub fn new(value: impl Into) -> Self { + Self { + value: value.into(), + } + } +} + +/// Provenance metadata captured per chunk at ingest time. +/// +/// Captures at minimum: source type, source identifier, owner/account, +/// timestamps, and tags/labels when available. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct Metadata { + /// Which upstream source kind produced this chunk. + pub source_kind: SourceKind, + /// Stable logical id for the ingestion group (channel id, thread id, doc id). + /// + /// Chat: channel/group id. Email: thread id. Document: doc id. + pub source_id: String, + /// Account or user the content belongs to. Empty string for anonymous / system sources. + pub owner: String, + /// Point-in-time timestamp for ordering within a source. + /// + /// For chats = message time; for emails = message sent time; + /// for documents = last-modified or ingest time. + #[serde(with = "chrono::serde::ts_milliseconds")] + pub timestamp: DateTime, + /// Covering time range the chunk spans. For a single leaf it usually equals + /// `(timestamp, timestamp)`; for later summary nodes it widens to cover all + /// children. + #[serde(with = "time_range_serde")] + pub time_range: (DateTime, DateTime), + /// Arbitrary labels / tags carried through from the source (e.g. Gmail labels, + /// Slack reactions, Notion tags). Ingest does not interpret these. + #[serde(default)] + pub tags: Vec, + /// Opaque pointer back to the raw source record for drill-down / citation. + pub source_ref: Option, + /// When set, overrides `source_id` for the chunk file path so multiple + /// items share one directory. `source_id` remains the dedup key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path_scope: Option, +} + +impl Metadata { + /// Convenience constructor used by canonicalisers: point timestamp, + /// `time_range = (timestamp, timestamp)`. + pub fn point_in_time( + source_kind: SourceKind, + source_id: impl Into, + owner: impl Into, + timestamp: DateTime, + ) -> Self { + Self { + source_kind, + source_id: source_id.into(), + owner: owner.into(), + timestamp, + time_range: (timestamp, timestamp), + tags: Vec::new(), + source_ref: None, + path_scope: None, + } + } +} + +/// A single ingested chunk — the atomic persistence unit. +/// +/// In the design this is the leaf of a source tree. Later phases build summary +/// nodes on top of these leaves; here they live standalone. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct Chunk { + /// Deterministic id derived from (source_kind, source_id, seq_in_source, content). + pub id: String, + /// Canonical Markdown content. + pub content: String, + /// Provenance metadata. + pub metadata: Metadata, + /// Token count (rough heuristic — 1 token ≈ 4 chars). + pub token_count: u32, + /// Sequence number of this chunk inside its logical source. Stable and + /// starts at 0 for the first chunk of a source. + pub seq_in_source: u32, + /// When this chunk was persisted to the local store. + #[serde(with = "chrono::serde::ts_milliseconds")] + pub created_at: DateTime, + /// True when this chunk is a sub-split of a single logical unit (e.g. a + /// chat message or email body that exceeded `max_tokens`). Each piece + /// carries this flag so downstream scorers can lower its weight relative to + /// whole-unit chunks. + #[serde(default)] + pub partial_message: bool, +} + +/// A chunk staged for the MD-content write path: a [`Chunk`] whose full body +/// lives on disk at `content_path` (with `content_sha256` for integrity), while +/// the SQLite `content` column carries only a ≤500-char preview. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StagedChunk { + /// The chunk being persisted. + pub chunk: Chunk, + /// Forward-slash relative path (under the content root) where the full body lives. + pub content_path: String, + /// Hex SHA-256 of the on-disk body, recorded for integrity checks. + pub content_sha256: String, +} + +/// Deterministic chunk id. +/// +/// `sha256(source_kind | "\0" | source_id | "\0" | seq | "\0" | content)` +/// hex-encoded, first 32 chars (128 bits of collision resistance). +/// +/// Content is included so multiple ingest calls that share a `source_id` don't +/// collide on `seq=0,1,2,…`. Re-ingesting the same canonical content under the +/// same `(source_id, seq)` still produces the same id, so upserts stay +/// idempotent. +pub fn chunk_id( + source_kind: SourceKind, + source_id: &str, + seq_in_source: u32, + content: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(source_kind.as_str().as_bytes()); + hasher.update([0u8]); + hasher.update(source_id.as_bytes()); + hasher.update([0u8]); + hasher.update(seq_in_source.to_be_bytes()); + hasher.update([0u8]); + hasher.update(content.as_bytes()); + let digest = hasher.finalize(); + let hex = digest.iter().fold(String::with_capacity(64), |mut acc, b| { + use std::fmt::Write; + let _ = write!(acc, "{b:02x}"); + acc + }); + hex[..32].to_string() +} + +/// Approximate token count (GPT-family heuristic: 1 token ≈ 4 chars). +pub fn approx_token_count(text: &str) -> u32 { + // saturating_add guards against absurdly long inputs + let chars = text.chars().count() as u32; + chars.saturating_add(3) / 4 +} + +/// Per-character weight in **quarter-token** units for +/// [`conservative_token_estimate`]. Deliberately pessimistic so the chunker and +/// the embed backstop never under-split: real SentencePiece/WordPiece output for +/// hash-, code-, and markdown-dense text approaches ~1 token/char — far above +/// the `chars/4` GPT heuristic in [`approx_token_count`]. +fn char_token_quarters(ch: char) -> u32 { + if ch.is_ascii_alphanumeric() { + 2 // 0.50 token/char — alphanumeric runs pack ~2-4 chars per token + } else if ch.is_whitespace() { + 1 // 0.25 token/char — whitespace usually merges into adjacent pieces + } else { + 4 // 1.00 token/char — ASCII punctuation/symbols AND all non-ASCII + // (Hebrew/CJK/emoji), which tokenise ~1 piece per char or worse + } +} + +/// Conservative (over-estimating) token count, for embed-safety decisions only. +/// +/// [`approx_token_count`] (`chars/4`) under-counts dense markdown/hash/code by +/// ~5×. This weights characters by class so the result is an upper-ish bound on +/// real tokeniser output. It does **not** replace `approx_token_count`, which +/// still drives summariser/seal token budgeting. +pub fn conservative_token_estimate(text: &str) -> u32 { + let quarters: u64 = text + .chars() + .map(|c| u64::from(char_token_quarters(c))) + .sum(); + let tokens = quarters.div_ceil(4); // ceil(quarters / 4) + tokens.min(u64::from(u32::MAX)) as u32 +} + +/// Largest leading slice of `text` whose [`conservative_token_estimate`] is +/// ≤ `budget`, ending on a UTF-8 char boundary. Returns the whole string when +/// already within budget. Used as the embed-path backstop so an over-long body +/// can never be sent to the embedder above its input limit. +pub fn truncate_to_conservative_tokens(text: &str, budget: u32) -> &str { + if conservative_token_estimate(text) <= budget { + return text; + } + let cap = u64::from(budget).saturating_mul(4); // quarter-tokens + let mut acc: u64 = 0; + for (idx, ch) in text.char_indices() { + let q = u64::from(char_token_quarters(ch)); + if acc + q > cap { + return &text[..idx]; + } + acc += q; + } + text +} + +/// `serde(with = ...)` shim for `(DateTime, DateTime)`. +/// +/// Chrono has no built-in serde helper for a *pair* of timestamps, so this +/// mirrors `chrono::serde::ts_milliseconds` but for a 2-tuple: each endpoint +/// round-trips through millisecond-since-epoch integers under the field +/// names `start_ms` / `end_ms`. +mod time_range_serde { + use chrono::{DateTime, TimeZone, Utc}; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + /// On-wire shape: millisecond-since-epoch pair. + #[derive(Serialize, Deserialize)] + struct Wire { + start_ms: i64, + end_ms: i64, + } + + /// Serialize a `(start, end)` UTC timestamp pair as `{start_ms, end_ms}`. + pub fn serialize( + value: &(DateTime, DateTime), + serializer: S, + ) -> Result { + Wire { + start_ms: value.0.timestamp_millis(), + end_ms: value.1.timestamp_millis(), + } + .serialize(serializer) + } + + /// Deserialize a `{start_ms, end_ms}` pair back into UTC timestamps. + /// + /// # Errors + /// Returns a `serde` custom error if either millisecond value does not + /// map to a valid `DateTime` (chrono's `timestamp_millis_opt` fails, + /// e.g. out-of-range values). + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result<(DateTime, DateTime), D::Error> { + let wire = Wire::deserialize(deserializer)?; + let start = Utc + .timestamp_millis_opt(wire.start_ms) + .single() + .ok_or_else(|| serde::de::Error::custom("invalid start_ms"))?; + let end = Utc + .timestamp_millis_opt(wire.end_ms) + .single() + .ok_or_else(|| serde::de::Error::custom("invalid end_ms"))?; + Ok((start, end)) + } +} + +#[cfg(test)] +#[path = "chunks_tests.rs"] +mod tests; diff --git a/api/src/chunks_tests.rs b/api/src/chunks_tests.rs new file mode 100644 index 0000000..3d9c89f --- /dev/null +++ b/api/src/chunks_tests.rs @@ -0,0 +1,209 @@ +//! Unit tests for the chunk model (`super`). + +use super::*; +use chrono::TimeZone; + +#[test] +fn chunk_id_is_deterministic() { + let a = chunk_id(SourceKind::Chat, "slack:#eng", 0, "hello"); + let b = chunk_id(SourceKind::Chat, "slack:#eng", 0, "hello"); + assert_eq!(a, b); + assert_eq!(a, "95785e45df3ff65599a71866e0412993"); + assert_eq!(a.len(), 32); +} + +#[test] +fn conservative_estimate_weights_by_char_class() { + assert_eq!(conservative_token_estimate("abcd"), 2); // 4 alnum × 2q / 4 + assert_eq!(conservative_token_estimate(" "), 1); // 4 ws × 1q / 4 + assert_eq!(conservative_token_estimate("....,,,,"), 8); // 8 punct × 4q / 4 + assert_eq!(conservative_token_estimate("שלום"), 4); // 4 non-ascii × 4q / 4 + assert_eq!(conservative_token_estimate(""), 0); +} + +#[test] +fn conservative_estimate_exceeds_approx_for_dense_content() { + let dense = "claude-memory:openhuman:MEMORY.md:67d6fe2727d431b16d41630babfdcf1cdf61bda7b9ba\n" + .repeat(40); + assert!( + conservative_token_estimate(&dense) > approx_token_count(&dense), + "conservative estimate must exceed chars/4 on dense content", + ); +} + +#[test] +fn truncate_respects_budget_and_char_boundaries() { + let text = "שלום עולם ".repeat(100); // Hebrew, ~1 token/char + let out = truncate_to_conservative_tokens(&text, 10); + assert!(conservative_token_estimate(out) <= 10); + assert!(text.starts_with(out)); // valid prefix on a char boundary + assert!(out.len() < text.len()); +} + +#[test] +fn truncate_is_noop_within_budget() { + let text = "short and sweet"; + assert_eq!(truncate_to_conservative_tokens(text, 1000), text); +} + +#[test] +fn chunk_id_varies_with_seq() { + let a = chunk_id(SourceKind::Chat, "slack:#eng", 0, "hello"); + let b = chunk_id(SourceKind::Chat, "slack:#eng", 1, "hello"); + assert_ne!(a, b); +} + +#[test] +fn chunk_id_varies_with_source_kind() { + let a = chunk_id(SourceKind::Chat, "foo", 0, "hello"); + let b = chunk_id(SourceKind::Email, "foo", 0, "hello"); + assert_ne!(a, b); +} + +#[test] +fn chunk_id_varies_with_source_id() { + let a = chunk_id(SourceKind::Chat, "x", 0, "hello"); + let b = chunk_id(SourceKind::Chat, "y", 0, "hello"); + assert_ne!(a, b); +} + +#[test] +fn chunk_id_varies_with_content() { + let a = chunk_id(SourceKind::Chat, "slack:c1", 0, "bucket A content"); + let b = chunk_id(SourceKind::Chat, "slack:c1", 0, "bucket B content"); + assert_ne!(a, b); +} + +#[test] +fn source_kind_round_trip() { + for kind in [SourceKind::Chat, SourceKind::Email, SourceKind::Document] { + assert_eq!(SourceKind::parse(kind.as_str()).unwrap(), kind); + } +} + +#[test] +fn data_source_round_trip() { + for ds in DataSource::all() { + assert_eq!(DataSource::parse(ds.as_str()).unwrap(), *ds); + } +} + +#[test] +fn data_source_has_all_variants() { + assert_eq!(DataSource::all().len(), 9); +} + +#[test] +fn data_source_kind_mapping() { + use DataSource::*; + for ds in [Discord, Telegram, Whatsapp, Conversation] { + assert_eq!(ds.kind(), SourceKind::Chat); + } + for ds in [Gmail, OtherEmail] { + assert_eq!(ds.kind(), SourceKind::Email); + } + for ds in [Notion, MeetingNotes, DriveDocs] { + assert_eq!(ds.kind(), SourceKind::Document); + } +} + +#[test] +fn data_source_parse_rejects_unknown() { + assert!(DataSource::parse("nope").is_err()); + assert!(DataSource::parse("Discord").is_err()); // case-sensitive + assert!(DataSource::parse("drive docs").is_err()); // no spaces +} + +#[test] +fn data_source_serde_is_snake_case() { + let ds = DataSource::MeetingNotes; + let json = serde_json::to_string(&ds).unwrap(); + assert_eq!(json, "\"meeting_notes\""); + let parsed: DataSource = serde_json::from_str("\"meeting_notes\"").unwrap(); + assert_eq!(parsed, ds); +} + +#[test] +fn approx_token_count_scales_linearly() { + assert_eq!(approx_token_count(""), 0); + assert_eq!(approx_token_count("a"), 1); // 1→1 + assert_eq!(approx_token_count("abcd"), 1); // 4→1 + assert_eq!(approx_token_count("abcde"), 2); // 5→2 + assert_eq!(approx_token_count(&"x".repeat(400)), 100); +} + +#[test] +fn source_kind_parse_rejects_unknown_wire_values() { + assert_eq!( + SourceKind::parse("video").unwrap_err(), + "unknown source kind: video" + ); +} + +#[test] +fn metadata_constructor_and_source_ref_fill_documented_defaults() { + let timestamp = Utc.timestamp_millis_opt(1_700_000_000_123).unwrap(); + let mut metadata = Metadata::point_in_time(SourceKind::Document, "doc-1", "alice", timestamp); + metadata.source_ref = Some(SourceRef::new("notion://doc-1")); + + assert_eq!(metadata.source_id, "doc-1"); + assert_eq!(metadata.owner, "alice"); + assert_eq!(metadata.time_range, (timestamp, timestamp)); + assert!(metadata.tags.is_empty()); + assert_eq!(metadata.source_ref.unwrap().value, "notion://doc-1"); +} + +#[test] +fn chunk_json_round_trips_millisecond_time_range_and_partial_default() { + let timestamp = Utc.timestamp_millis_opt(1_700_000_000_123).unwrap(); + let chunk = Chunk { + id: "chunk".into(), + content: "body".into(), + metadata: Metadata::point_in_time(SourceKind::Chat, "channel", "alice", timestamp), + token_count: 1, + seq_in_source: 0, + created_at: timestamp, + partial_message: true, + }; + let encoded = serde_json::to_value(&chunk).unwrap(); + assert_eq!( + encoded["metadata"]["time_range"]["start_ms"], + timestamp.timestamp_millis() + ); + assert_eq!(serde_json::from_value::(encoded).unwrap(), chunk); + + let mut legacy = serde_json::to_value(&chunk).unwrap(); + legacy.as_object_mut().unwrap().remove("partial_message"); + assert!( + !serde_json::from_value::(legacy) + .unwrap() + .partial_message + ); +} + +#[test] +fn chunk_json_rejects_out_of_range_time_range_endpoints() { + let timestamp = Utc.timestamp_millis_opt(1_700_000_000_123).unwrap(); + let chunk = Chunk { + id: "chunk".into(), + content: "body".into(), + metadata: Metadata::point_in_time(SourceKind::Chat, "channel", "alice", timestamp), + token_count: 1, + seq_in_source: 0, + created_at: timestamp, + partial_message: false, + }; + let mut encoded = serde_json::to_value(chunk).unwrap(); + encoded["metadata"]["time_range"]["start_ms"] = serde_json::json!(i64::MAX); + assert!(serde_json::from_value::(encoded.clone()) + .unwrap_err() + .to_string() + .contains("invalid start_ms")); + + encoded["metadata"]["time_range"]["start_ms"] = serde_json::json!(0); + encoded["metadata"]["time_range"]["end_ms"] = serde_json::json!(i64::MAX); + assert!(serde_json::from_value::(encoded) + .unwrap_err() + .to_string() + .contains("invalid end_ms")); +} diff --git a/api/src/error.rs b/api/src/error.rs new file mode 100644 index 0000000..c9e6405 --- /dev/null +++ b/api/src/error.rs @@ -0,0 +1,104 @@ +//! Engine-level error type shared by ported modules that want a typed error +//! surface. Modules that mirror OpenHuman's `anyhow`-based signatures may keep +//! using `anyhow::Result`; this enum is for contracts that benefit from +//! matchable variants (validation, not-found, taint, IO). +//! +//! `?` converts `std::io::Error` and `serde_json::Error` into +//! [`MemoryError::Io`] / [`MemoryError::Serde`] automatically via the derived +//! `#[from]` impls, and any `anyhow::Error` (including one produced by `?` on +//! a foreign error type inside an `anyhow`-returning function) into +//! [`MemoryError::Other`]. The purpose-built variants ([`MemoryError::NotFound`], +//! [`MemoryError::Invalid`], [`MemoryError::BudgetExceeded`], +//! [`MemoryError::PathEscape`]) are constructed explicitly by callers that want +//! matchable, typed failure — they are never inferred from a foreign error. +//! +//! [`MemoryError::Unsupported`] is the one variant that belongs to the *driver +//! contract* rather than the engine: it is what a caller gets when a bound +//! driver does not implement the capability family a call needs. See its docs +//! for why that should be rare. + +use thiserror::Error; + +use crate::capabilities::Capability; + +/// Errors surfaced by the memory engine. +#[derive(Debug, Error)] +pub enum MemoryError { + /// A requested record / source / node was not found. + #[error("not found: {0}")] + NotFound(String), + /// Caller-supplied input failed validation. + #[error("invalid input: {0}")] + Invalid(String), + /// A configured budget (tokens, cost, depth) was exceeded. + #[error("budget exceeded: {0}")] + BudgetExceeded(String), + /// A path escaped the workspace sandbox (symlink / traversal). + #[error("path escapes workspace: {0}")] + PathEscape(String), + /// Underlying IO failure. + #[error("io error: {0}")] + Io(#[from] std::io::Error), + /// Serialization / deserialization failure. + #[error("serde error: {0}")] + Serde(#[from] serde_json::Error), + /// The bound driver does not implement the named capability family. + /// + /// This should be **rare**, because capabilities are negotiated once at + /// bind time and the kernel unregisters the RPC methods and omits the agent + /// tools of every unadvertised family. Reaching this variant means one of: + /// + /// - an out-of-process driver answered `501` for a family its handshake + /// claimed (the case [`crate::capabilities`] cannot pre-empt); + /// - a caller bypassed the capability filter — a kernel bug. + /// + /// ## Why the payload is an owned `String` and not a [`Capability`] + /// + /// The transport adapter constructs this from a wire response, where the + /// family is a runtime string that may not be a known [`Capability`] at all + /// — a driver speaking a newer minor contract version, a vendor extension, + /// or simply a typo in a third-party backend. A `Capability` field would + /// force the adapter to drop that information or fail parsing, and a + /// `&'static str` cannot be produced from a runtime value without leaking + /// memory. An owned `String` is the only representation that round-trips + /// every case. + /// + /// Construct it with [`MemoryError::unsupported`] when the family is known + /// (that path yields the canonical [`Capability::as_str`] spelling) and + /// with [`MemoryError::unsupported_raw`] when it came off the wire. + #[error("unsupported capability: {capability}")] + Unsupported { + /// Wire name of the capability family that is not supported — + /// [`Capability::as_str`] when known, otherwise the raw string the + /// driver reported. + capability: String, + }, + /// Catch-all wrapping an opaque lower-level error. + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl MemoryError { + /// Builds [`MemoryError::Unsupported`] for a family this build knows, + /// using its canonical [`Capability::as_str`] spelling. + pub fn unsupported(capability: Capability) -> Self { + Self::Unsupported { + capability: capability.as_str().to_string(), + } + } + + /// Builds [`MemoryError::Unsupported`] from a family name that came off the + /// wire and may not correspond to any known [`Capability`]. + pub fn unsupported_raw(capability: impl Into) -> Self { + Self::Unsupported { + capability: capability.into(), + } + } +} + +/// Convenience result alias for engine-level fallible operations. +pub type MemoryEngineResult = Result; + +#[cfg(test)] +#[path = "error_tests.rs"] +mod tests; diff --git a/api/src/error_tests.rs b/api/src/error_tests.rs new file mode 100644 index 0000000..75d72bc --- /dev/null +++ b/api/src/error_tests.rs @@ -0,0 +1,59 @@ +//! Unit tests for [`super::MemoryError`], focused on the `Unsupported` variant +//! added for the driver contract. The older variants are exercised where they +//! are constructed, in the engine crate. + +use super::*; +use crate::capabilities::Capability; + +#[test] +fn unsupported_from_a_known_capability_uses_the_canonical_wire_name() { + for capability in Capability::ALL { + let err = MemoryError::unsupported(capability); + match err { + MemoryError::Unsupported { + capability: ref got, + } => { + assert_eq!(got, capability.as_str()); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } +} + +#[test] +fn unsupported_raw_preserves_a_family_this_build_does_not_know() { + // The reason the payload is an owned `String`: a driver speaking a newer + // minor contract version can name a family that is not a `Capability` here, + // and the adapter must be able to report it verbatim. + let err = MemoryError::unsupported_raw("holographic_recall"); + match err { + MemoryError::Unsupported { ref capability } => { + assert_eq!(capability, "holographic_recall"); + assert!(Capability::parse(capability).is_err()); + } + other => panic!("expected Unsupported, got {other:?}"), + } +} + +#[test] +fn unsupported_display_names_the_capability() { + assert_eq!( + MemoryError::unsupported(Capability::Tree).to_string(), + "unsupported capability: tree" + ); + assert_eq!( + MemoryError::unsupported(Capability::ToolMemory).to_string(), + "unsupported capability: tool_memory" + ); +} + +#[test] +fn unsupported_is_distinguishable_from_the_other_variants() { + // A transport adapter maps `501` to `Unsupported` and everything else + // elsewhere, so the variant must not collide with `Invalid` / `NotFound`. + let unsupported = MemoryError::unsupported(Capability::Diff); + assert!(matches!(unsupported, MemoryError::Unsupported { .. })); + + let invalid = MemoryError::Invalid("diff".to_string()); + assert!(!matches!(invalid, MemoryError::Unsupported { .. })); +} diff --git a/api/src/goals.rs b/api/src/goals.rs new file mode 100644 index 0000000..697a867 --- /dev/null +++ b/api/src/goals.rs @@ -0,0 +1,131 @@ +//! Domain types for the agent's long-term goals list. +//! +//! Goals are a small, ordered list of durable objectives the agent holds when +//! interacting with the user. They are persisted as a compact markdown document +//! (`MEMORY_GOALS.md`) by the engine crate's `memory::goals::store` and +//! surfaced over RPC + agent tools. Each item carries a stable short id so +//! edit/delete operations can address a specific line without depending on +//! ordering. +//! +//! This module is **pure data**: it owns the shape, parse, and render only. +//! The validating mutation surface (`add` / `edit` / `delete`) lives next to +//! the `regex`-backed PII/secret predicates it calls, in the engine crate's +//! `memory::goals::store::GoalsDocMutations` trait, so the value types stay +//! free of the safety machinery and of `regex`. The cap-enforcing persistence +//! layer and the reflection apply/dedupe logic live in the engine crate too. + +use serde::{Deserialize, Serialize}; + +/// Markdown header rendered at the top of `MEMORY_GOALS.md`. +pub(crate) const HEADER: &str = "# Long-term Goals"; + +/// A single long-term goal item. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GoalItem { + /// Stable short id (e.g. `g1`). Used as the dedupe/address key for + /// `edit`/`delete`. Rendered inline in the markdown as `- [g1] …`. + pub id: String, + /// The goal text — one concise sentence. + pub text: String, +} + +impl GoalItem { + /// Construct a goal item from an id + text, trimming surrounding + /// whitespace from the text. + pub fn new(id: impl Into, text: impl Into) -> Self { + Self { + id: id.into(), + text: text.into().trim().to_string(), + } + } +} + +/// The full goals document — an ordered list of [`GoalItem`]s. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct GoalsDoc { + /// Ordered goal items. Order is meaningful for rendering and cap trimming + /// (oldest = front). + pub items: Vec, +} + +impl GoalsDoc { + /// Parse a `MEMORY_GOALS.md` body into a [`GoalsDoc`]. + /// + /// Recognised item lines look like `- [g1] do the thing`. Lines that don't + /// match (the header, blank lines, free prose) are ignored so a + /// hand-edited file degrades gracefully rather than erroring. + pub fn parse(body: &str) -> Self { + let mut items = Vec::new(); + for line in body.lines() { + let trimmed = line.trim(); + // Strip the leading list marker, if present. + let rest = match trimmed.strip_prefix("- ") { + Some(r) => r.trim(), + None => continue, + }; + // Expect `[id] text`. + let Some(after_open) = rest.strip_prefix('[') else { + continue; + }; + let Some(close_idx) = after_open.find(']') else { + continue; + }; + let id = after_open[..close_idx].trim(); + let text = after_open[close_idx + 1..].trim(); + if id.is_empty() || text.is_empty() { + continue; + } + items.push(GoalItem::new(id, text)); + } + Self { items } + } + + /// Render the document back to markdown suitable for `MEMORY_GOALS.md`. + /// + /// NOTE: this emits only the header and the recognised `- [id] text` + /// item lines — any free prose, sub-bullets, or other hand-added content + /// a user wrote into the file is not represented in [`GoalsDoc`] and is + /// therefore dropped on the next `parse` → mutate → `render` round-trip + /// (e.g. via `add`/`edit`/`delete`/reflection). Treat this file as + /// machine-owned rather than freely hand-editable. + pub fn render(&self) -> String { + let mut out = String::from(HEADER); + out.push_str("\n\n"); + for item in &self.items { + out.push_str(&format!("- [{}] {}\n", item.id, item.text)); + } + out + } + + /// Whether the list currently has no items. Used to drive the + /// "first run / initial population" reflection behaviour. + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + /// Number of goal items currently held. + pub fn len(&self) -> usize { + self.items.len() + } + + /// Allocate the next free `g` id not already used in the list. + pub fn next_id(&self) -> String { + let mut n = self.items.len() + 1; + loop { + let candidate = format!("g{n}"); + if !self.items.iter().any(|i| i.id == candidate) { + return candidate; + } + n += 1; + } + } + + /// Whether the list already holds `id`. + pub fn contains_id(&self, id: &str) -> bool { + self.items.iter().any(|i| i.id == id) + } +} + +#[cfg(test)] +#[path = "goals_tests.rs"] +mod tests; diff --git a/api/src/goals_tests.rs b/api/src/goals_tests.rs new file mode 100644 index 0000000..e67410c --- /dev/null +++ b/api/src/goals_tests.rs @@ -0,0 +1,22 @@ +//! Unit tests for [`super::GoalsDoc`] parse/render — the pure-data half. +//! +//! The validating mutation tests (`add` / `edit` / `delete`, including the +//! secret/PII rejection cases) live in the engine crate next to the +//! `GoalsDocMutations` trait that owns them: `memory::goals::mutations_tests`. + +use super::*; + +#[test] +fn render_starts_with_header() { + let doc = GoalsDoc::default(); + assert!(doc.render().starts_with("# Long-term Goals")); +} + +#[test] +fn parse_ignores_non_item_lines() { + let body = "# Long-term Goals\n\nsome stray prose\n- [g1] real goal\n- malformed line\n"; + let doc = GoalsDoc::parse(body); + assert_eq!(doc.items.len(), 1); + assert_eq!(doc.items[0].id, "g1"); + assert_eq!(doc.items[0].text, "real goal"); +} diff --git a/api/src/health.rs b/api/src/health.rs new file mode 100644 index 0000000..e98c324 --- /dev/null +++ b/api/src/health.rs @@ -0,0 +1,119 @@ +//! Liveness state a memory driver reports about itself. +//! +//! ## Why this lives in the contract crate and not in the host +//! +//! The OpenHuman kernel has (or will have) a *generic* subsystem-agnostic +//! `DriverHealth` shared by memory, inference, channels, and sandbox. This crate +//! cannot name that type: `tinymemory-api` is the contract a third-party driver +//! compiles against, and a driver must be able to depend on it without pulling +//! in the OpenHuman host — nor should the next subsystem cut over inherit +//! generic kernel vocabulary from a *memory* crate. +//! +//! So the contract carries its own [`MemoryHealth`], and the host's memory +//! adapter converts. The conversion is deliberately trivial and lossless: this +//! is a **small closed enum with a reason string**, shaped one-for-one against +//! the kernel's `Ready | Degraded { reason } | Down { reason }`, not a +//! free-form struct that would need field-by-field mapping and would drift. +//! Keep it that way — if a driver needs to report something richer, it belongs +//! in a driver-specific status payload, not here. +//! +//! ## Wire form +//! +//! Serializes as an internally-tagged object with a stable snake_case `status` +//! discriminant, which is also the shape of the transport adapter's +//! `GET /v1/health` → `{ status, reason }` response: +//! +//! ```json +//! { "status": "ready" } +//! { "status": "degraded", "reason": "vector index rebuilding" } +//! { "status": "down", "reason": "connection refused" } +//! ``` + +use serde::{Deserialize, Serialize}; + +/// Health of a bound memory driver, as the driver reports it. +/// +/// The three states are ordered by severity and mean different things to the +/// kernel: +/// +/// - [`MemoryHealth::Ready`] — serve traffic normally. +/// - [`MemoryHealth::Degraded`] — still serve traffic, but surface the reason +/// in status output; results may be incomplete or slow. +/// - [`MemoryHealth::Down`] — do not serve traffic; the bind should be surfaced +/// as failed and, per the fallback rule, the embedded default rebound. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum MemoryHealth { + /// The driver is reachable and serving requests normally. + Ready, + /// The driver is serving requests, but something is wrong and the caller + /// should surface it. Results may be incomplete, stale, or slow. + Degraded { + /// Operator-facing explanation. Must not contain credentials, tokens, + /// or user memory content — this string is logged and shown in status + /// output. + reason: String, + }, + /// The driver cannot serve requests at all. + Down { + /// Operator-facing explanation, subject to the same redaction rule as + /// [`MemoryHealth::Degraded::reason`]. + reason: String, + }, +} + +impl MemoryHealth { + /// Convenience constructor for [`MemoryHealth::Degraded`]. + pub fn degraded(reason: impl Into) -> Self { + Self::Degraded { + reason: reason.into(), + } + } + + /// Convenience constructor for [`MemoryHealth::Down`]. + pub fn down(reason: impl Into) -> Self { + Self::Down { + reason: reason.into(), + } + } + + /// Stable snake_case discriminant, matching the serialized `status` field. + pub fn as_str(&self) -> &'static str { + match self { + Self::Ready => "ready", + Self::Degraded { .. } => "degraded", + Self::Down { .. } => "down", + } + } + + /// The operator-facing reason, when there is one. `None` for + /// [`MemoryHealth::Ready`]. + pub fn reason(&self) -> Option<&str> { + match self { + Self::Ready => None, + Self::Degraded { reason } | Self::Down { reason } => Some(reason.as_str()), + } + } + + /// Whether the kernel should route traffic to this driver. + /// + /// True for [`MemoryHealth::Ready`] and [`MemoryHealth::Degraded`] — a + /// degraded driver is still the bound driver — and false for + /// [`MemoryHealth::Down`]. + pub fn is_usable(&self) -> bool { + !matches!(self, Self::Down { .. }) + } +} + +impl std::fmt::Display for MemoryHealth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.reason() { + Some(reason) => write!(f, "{}: {reason}", self.as_str()), + None => f.write_str(self.as_str()), + } + } +} + +#[cfg(test)] +#[path = "health_tests.rs"] +mod tests; diff --git a/api/src/health_tests.rs b/api/src/health_tests.rs new file mode 100644 index 0000000..a31c965 --- /dev/null +++ b/api/src/health_tests.rs @@ -0,0 +1,90 @@ +//! Unit tests for [`super::MemoryHealth`]. +//! +//! These pin the two properties the host's memory adapter depends on: the +//! variant set is closed and small enough for a lossless `match` into the +//! kernel's generic `DriverHealth`, and the wire form carries a stable +//! `status` discriminant plus a `reason`. + +use super::*; +use serde_json::json; + +#[test] +fn ready_has_no_reason_and_is_usable() { + let health = MemoryHealth::Ready; + assert_eq!(health.as_str(), "ready"); + assert_eq!(health.reason(), None); + assert!(health.is_usable()); + assert_eq!(health.to_string(), "ready"); +} + +#[test] +fn degraded_carries_a_reason_and_is_still_usable() { + let health = MemoryHealth::degraded("vector index rebuilding"); + assert_eq!(health.as_str(), "degraded"); + assert_eq!(health.reason(), Some("vector index rebuilding")); + // A degraded driver is still the bound driver. + assert!(health.is_usable()); + assert_eq!(health.to_string(), "degraded: vector index rebuilding"); +} + +#[test] +fn down_carries_a_reason_and_is_not_usable() { + let health = MemoryHealth::down("connection refused"); + assert_eq!(health.as_str(), "down"); + assert_eq!(health.reason(), Some("connection refused")); + assert!(!health.is_usable()); + assert_eq!(health.to_string(), "down: connection refused"); +} + +#[test] +fn health_serializes_with_a_stable_status_discriminant() { + assert_eq!( + serde_json::to_value(MemoryHealth::Ready).unwrap(), + json!({ "status": "ready" }) + ); + assert_eq!( + serde_json::to_value(MemoryHealth::degraded("slow")).unwrap(), + json!({ "status": "degraded", "reason": "slow" }) + ); + assert_eq!( + serde_json::to_value(MemoryHealth::down("gone")).unwrap(), + json!({ "status": "down", "reason": "gone" }) + ); +} + +#[test] +fn health_round_trips_through_serde() { + for health in [ + MemoryHealth::Ready, + MemoryHealth::degraded("reindexing"), + MemoryHealth::down("auth expired"), + ] { + let encoded = serde_json::to_string(&health).unwrap(); + let decoded: MemoryHealth = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded, health); + } +} + +#[test] +fn health_constructors_match_their_variants() { + assert_eq!( + MemoryHealth::degraded("x"), + MemoryHealth::Degraded { + reason: "x".to_string() + } + ); + assert_eq!( + MemoryHealth::down("y"), + MemoryHealth::Down { + reason: "y".to_string() + } + ); +} + +#[test] +fn degraded_without_a_reason_is_rejected_on_the_wire() { + // `reason` is mandatory: a degraded/down driver that explains nothing is + // useless in status output, so the contract refuses to decode it. + assert!(serde_json::from_value::(json!({ "status": "degraded" })).is_err()); + assert!(serde_json::from_value::(json!({ "status": "down" })).is_err()); +} diff --git a/api/src/lib.rs b/api/src/lib.rs new file mode 100644 index 0000000..c092b87 --- /dev/null +++ b/api/src/lib.rs @@ -0,0 +1,75 @@ +//! Stable public contracts for the TinyMemory memory system. +//! +//! This crate holds the value types, error enum, capability vocabulary, and +//! storage trait that memory engines and their embedding hosts compile +//! against. It is engine-neutral on purpose: `tinycortex` is the default +//! embedded engine, not the owner of the contract, and a second engine +//! (`supermemory`, `mem0`, a self-hosted HTTP backend) implements the same +//! traits without either engine learning about the other. +//! It is deliberately dependency-light (serde / serde_json / +//! chrono / sha2 / anyhow / thiserror / async-trait / uuid only) so depending on +//! the contract never drags in SQLite, git2, reqwest, regex, or an async +//! runtime. +//! +//! ## Self-contained by design +//! +//! Nothing here names a host type. A third-party memory driver must be able to +//! depend on this crate alone, and the *generic* subsystem/driver vocabulary of +//! the OpenHuman kernel (`Driver`, `DriverClass`, `SubsystemRegistry`, the +//! policy `Guard`) must not be inherited from a *memory* crate by whichever +//! subsystem is cut over next. So the contract carries its own identity, +//! capability, and health vocabulary, and the host's memory adapter converts at +//! the boundary — see [`health`] for the shape that conversion relies on. +//! +//! Driver *class* (embedded / external / null) is deliberately **absent**: that +//! is a host configuration fact about how a driver was bound, not something a +//! driver reports about itself. +//! +//! ## The TinyCortex engine's historical paths still resolve +//! +//! This contract used to live in the TinyCortex repository as `tinycortex-api`. +//! That crate is now a deprecated re-export of this one, and the engine crate +//! aliases these modules back into their historical paths +//! (`tinycortex::memory::{types, error, traits}`, +//! `tinycortex::memory::chunks::types`, `tinycortex::memory::tree::runtime::types`, +//! `tinycortex::memory::tool_memory::types`, `tinycortex::memory::goals::types`), +//! so every existing path keeps resolving unchanged. +//! +//! ## Module map +//! +//! - [`types`]: pure data contracts (entries, hits, taint, namespaces). +//! - [`recall`]: the borrowed [`recall::RecallOpts`] and owned, serde-derived +//! [`recall::OwnedRecallOpts`] recall filters (both re-exported from +//! [`types`]). +//! - [`capabilities`]: the thirteen [`capabilities::Capability`] families and +//! the [`capabilities::Capabilities`] set negotiated at bind time. +//! - [`provider`]: the driver contract — [`provider::MemoryProvider`] plus the +//! thirteen capability family traits and the value types they need. +//! - [`null`]: [`null::NullMemoryProvider`], the reference driver a +//! compiled-out or unconfigured memory subsystem binds to. +//! - [`health`]: [`health::MemoryHealth`], the liveness state a driver reports. +//! - [`version`]: [`CONTRACT_VERSION`] and the [`is_compatible`] bind rule. +//! - [`error`]: the typed [`error::MemoryError`] enum and its result alias. +//! - [`traits`]: the [`traits::Memory`] storage-backend trait. +//! - [`chunks`]: the persisted chunk model ([`chunks::Chunk`], [`chunks::Metadata`], +//! [`chunks::SourceRef`], …) and the deterministic [`chunks::chunk_id`]. +//! - [`tree`]: the markdown summary-tree node model ([`tree::TreeNode`], +//! [`tree::NodeLevel`], [`tree::TreeStatus`], …). +//! - [`tool_memory`]: tool-scoped rule contracts ([`tool_memory::ToolMemoryRule`], …). +//! - [`goals`]: the long-term goals document ([`goals::GoalsDoc`], [`goals::GoalItem`]). + +pub mod capabilities; +pub mod chunks; +pub mod error; +pub mod goals; +pub mod health; +pub mod null; +pub mod provider; +pub mod recall; +pub mod tool_memory; +pub mod traits; +pub mod tree; +pub mod types; +pub mod version; + +pub use version::{is_compatible, CONTRACT_VERSION}; diff --git a/api/src/null.rs b/api/src/null.rs new file mode 100644 index 0000000..482bc5c --- /dev/null +++ b/api/src/null.rs @@ -0,0 +1,445 @@ +//! [`NullMemoryProvider`] — the reference driver that stores nothing. +//! +//! ## What it is for +//! +//! A memory subsystem that is compiled out, disabled by configuration, or +//! explicitly bound to `driver = "null"` still has to bind *something*: the +//! kernel's registry holds exactly one driver per slot, and code that reaches +//! the slot must find a value rather than an `Option` it has to unwrap at every +//! call site. This is that value. It replaces the hand-written per-domain +//! `stub.rs` files with one generic answer. +//! +//! It is also the fixture the capability-degradation tests bind: with it in the +//! slot, the ten optional families are unadvertised, so their RPC methods are +//! unregistered and their agent tools are absent — and the core still boots. +//! +//! And it is the existence proof for the mandatory set: if +//! [`crate::provider::MemoryCore`], [`crate::provider::MemoryRecall`], and +//! [`crate::provider::MemoryPortability`] could not be implemented without a +//! storage engine, they would be the wrong three to have made mandatory. +//! +//! ## `/dev/null` semantics, and what that costs +//! +//! Writes are **accepted and discarded**; reads return empty. This mirrors the +//! Unix device the driver is named after, and it is the only behaviour that +//! lets the mandatory three be advertised honestly: a `store` that returned +//! [`crate::error::MemoryError::Unsupported`] would contradict advertising +//! [`crate::capabilities::Capability::Core`], and one that returned a hard +//! error would turn every optional auto-capture into a user-visible failure. +//! +//! The cost is real: content written here is gone. That is acceptable for a +//! subsystem the operator turned off, and unacceptable as a fallback for a +//! driver that failed to bind — **that** case falls back to the embedded +//! default, never to this. Do not wire it as a general-purpose failure mode. +//! +//! ## Why it implements all thirteen families but advertises three +//! +//! The ten optional families are implemented and every method returns +//! [`crate::error::MemoryError::Unsupported`] naming its family, but the +//! `as_*` accessors return `None` and +//! [`crate::provider::MemoryProvider::capabilities`] lists only the mandatory +//! three. So: +//! +//! - through `&dyn MemoryProvider` — the only way product code sees a driver — +//! an unadvertised family is simply **unreachable**, which is the intended +//! degradation; +//! - through the concrete type, a direct call yields a typed, *named* +//! `Unsupported` error, which is what makes the contract's error mapping +//! testable without writing a second mock. +//! +//! [`crate::provider::audit_provider`] confirms the two views agree. + +use async_trait::async_trait; + +use crate::capabilities::{Capabilities, Capability}; +use crate::error::MemoryError; +use crate::goals::GoalsDoc; +use crate::health::MemoryHealth; +use crate::provider::types::{ + DiffReport, EntityHit, ExportPage, ExportRecord, ImportOutcome, IngestItem, IngestOutcome, + MaintenanceReport, SnapshotRef, SourceItem, SourceScope, +}; +use crate::provider::{ + MemoryCore, MemoryDiff, MemoryDocuments, MemoryEntities, MemoryGoals, MemoryGraph, + MemoryIngest, MemoryMaintenance, MemoryPortability, MemoryProvider, MemoryRecall, + MemorySourceSink, MemoryToolMemory, MemoryTree, +}; +use crate::recall::OwnedRecallOpts; +use crate::tool_memory::ToolMemoryRule; +use crate::tree::{IngestRequest, QueryResult, TreeStatus}; +use crate::types::{ + GraphRelationRecord, MemoryCategory, MemoryEntry, MemoryKvRecord, MemoryTaint, + NamespaceDocumentInput, NamespaceRetrievalContext, NamespaceSummary, StoredMemoryDocument, +}; + +/// The [`driver_id`](MemoryProvider::driver_id) this driver reports. +pub const NULL_DRIVER_ID: &str = "null"; + +/// Shorthand for the `Unsupported` error every unadvertised family returns. +fn unsupported(capability: Capability) -> Result { + Err(MemoryError::unsupported(capability)) +} + +/// A driver that accepts every write, discards it, and returns nothing. +/// +/// See the module documentation for what it is for, why writes are silently +/// dropped, and why it implements ten families it does not advertise. +#[derive(Debug, Clone, Copy, Default)] +pub struct NullMemoryProvider; + +impl NullMemoryProvider { + /// Construct the null driver. It holds no state, so every instance is + /// interchangeable. + pub const fn new() -> Self { + Self + } +} + +#[async_trait] +impl MemoryProvider for NullMemoryProvider { + fn driver_id(&self) -> &str { + NULL_DRIVER_ID + } + + /// Exactly the mandatory three. The ten optional families are implemented + /// below but deliberately not advertised, so they stay unreachable through + /// the trait object. + fn capabilities(&self) -> Capabilities { + Capabilities::mandatory() + } + + /// Always [`MemoryHealth::Ready`]: a driver with no backing store has + /// nothing that can be unreachable, and reporting `Degraded` would make + /// every status view of a deliberately-disabled subsystem look broken. + async fn health(&self) -> MemoryHealth { + MemoryHealth::Ready + } + + // The `as_*` accessors are all left at their `None` defaults: nothing + // optional is reachable through the trait object. That absence is the whole + // point of this driver, so overriding any of them would be the bug. +} + +#[async_trait] +impl MemoryCore for NullMemoryProvider { + /// Accepts and discards. See the module docs on `/dev/null` semantics. + async fn store( + &self, + _namespace: &str, + _key: &str, + _content: &str, + _category: MemoryCategory, + _session_id: Option<&str>, + _taint: MemoryTaint, + ) -> Result<(), MemoryError> { + Ok(()) + } + + async fn get(&self, _namespace: &str, _key: &str) -> Result, MemoryError> { + Ok(None) + } + + /// Always `Ok(false)`: nothing was ever stored, so nothing existed to + /// forget. Consistent with the idempotence the family requires. + async fn forget(&self, _namespace: &str, _key: &str) -> Result { + Ok(false) + } + + async fn list( + &self, + _namespace: Option<&str>, + _category: Option<&MemoryCategory>, + _session_id: Option<&str>, + ) -> Result, MemoryError> { + Ok(Vec::new()) + } + + async fn namespaces(&self) -> Result, MemoryError> { + Ok(Vec::new()) + } +} + +#[async_trait] +impl MemoryRecall for NullMemoryProvider { + async fn recall( + &self, + _query: &str, + _limit: usize, + _opts: &OwnedRecallOpts, + _scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + Ok(Vec::new()) + } +} + +#[async_trait] +impl MemoryPortability for NullMemoryProvider { + /// One empty, terminal page: no records and no continuation cursor, so a + /// caller's export loop terminates on the first iteration. + /// + /// This driver never issues a cursor (every page is the first and only + /// page), so any `Some(_)` cursor a caller passes back is necessarily one + /// this driver did not hand out — reject it rather than silently treating + /// it as a valid terminal page. + async fn export_page( + &self, + cursor: Option<&str>, + _limit: usize, + ) -> Result { + if cursor.is_some() { + return Err(MemoryError::Invalid( + "null provider does not issue export cursors".into(), + )); + } + + Ok(ExportPage::default()) + } + + /// Counts every record as skipped rather than imported. Reporting them as + /// imported would tell a migration its data landed somewhere it did not. + async fn import_records( + &self, + records: Vec, + ) -> Result { + Ok(ImportOutcome { + imported: 0, + skipped: u32::try_from(records.len()).unwrap_or(u32::MAX), + failed: 0, + errors: Vec::new(), + }) + } +} + +#[async_trait] +impl MemoryIngest for NullMemoryProvider { + async fn ingest_document(&self, _item: IngestItem) -> Result { + unsupported(Capability::Ingest) + } + + async fn ingest_chat(&self, _messages: Vec) -> Result { + unsupported(Capability::Ingest) + } +} + +#[async_trait] +impl MemoryDocuments for NullMemoryProvider { + async fn put_document(&self, _input: NamespaceDocumentInput) -> Result { + unsupported(Capability::Documents) + } + + async fn get_document( + &self, + _namespace: &str, + _key: &str, + ) -> Result, MemoryError> { + unsupported(Capability::Documents) + } + + async fn query_documents( + &self, + _namespace: &str, + _query: &str, + _limit: usize, + ) -> Result { + unsupported(Capability::Documents) + } +} + +#[async_trait] +impl MemoryTree for NullMemoryProvider { + async fn append(&self, _request: IngestRequest) -> Result<(), MemoryError> { + unsupported(Capability::Tree) + } + + async fn query_source( + &self, + _namespace: &str, + _source_id: &str, + _limit: usize, + _scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + unsupported(Capability::Tree) + } + + async fn drill_down( + &self, + _namespace: &str, + _node_id: &str, + ) -> Result { + unsupported(Capability::Tree) + } + + async fn seal(&self, _namespace: &str) -> Result { + unsupported(Capability::Tree) + } + + async fn cascade(&self, _namespace: &str) -> Result { + unsupported(Capability::Tree) + } +} + +#[async_trait] +impl MemoryEntities for NullMemoryProvider { + async fn entities( + &self, + _namespace: &str, + _query: Option<&str>, + _limit: usize, + ) -> Result, MemoryError> { + unsupported(Capability::Entities) + } + + async fn entity_edges( + &self, + _namespace: &str, + _entity_id: &str, + _limit: usize, + ) -> Result, MemoryError> { + unsupported(Capability::Entities) + } + + async fn touch_entities( + &self, + _namespace: &str, + _entity_ids: &[String], + ) -> Result<(), MemoryError> { + unsupported(Capability::Entities) + } +} + +#[async_trait] +impl MemoryGraph for NullMemoryProvider { + async fn kv_get( + &self, + _namespace: Option<&str>, + _key: &str, + ) -> Result, MemoryError> { + unsupported(Capability::Graph) + } + + async fn kv_put( + &self, + _namespace: Option<&str>, + _key: &str, + _value: serde_json::Value, + ) -> Result<(), MemoryError> { + unsupported(Capability::Graph) + } + + async fn kv_list( + &self, + _namespace: Option<&str>, + _prefix: Option<&str>, + _limit: usize, + ) -> Result, MemoryError> { + unsupported(Capability::Graph) + } + + async fn relations( + &self, + _namespace: Option<&str>, + _subject: Option<&str>, + _predicate: Option<&str>, + _limit: usize, + ) -> Result, MemoryError> { + unsupported(Capability::Graph) + } + + async fn put_relation(&self, _relation: GraphRelationRecord) -> Result<(), MemoryError> { + unsupported(Capability::Graph) + } +} + +#[async_trait] +impl MemoryDiff for NullMemoryProvider { + async fn capture_snapshot(&self, _source_id: &str) -> Result { + unsupported(Capability::Diff) + } + + async fn snapshots( + &self, + _source_id: &str, + _limit: usize, + ) -> Result, MemoryError> { + unsupported(Capability::Diff) + } + + async fn diff( + &self, + _source_id: &str, + _from: Option<&str>, + _to: &str, + ) -> Result { + unsupported(Capability::Diff) + } +} + +#[async_trait] +impl MemoryGoals for NullMemoryProvider { + async fn goals(&self) -> Result { + unsupported(Capability::Goals) + } + + async fn set_goals(&self, _goals: GoalsDoc) -> Result<(), MemoryError> { + unsupported(Capability::Goals) + } +} + +#[async_trait] +impl MemoryToolMemory for NullMemoryProvider { + async fn tool_rules(&self, _tool_name: &str) -> Result, MemoryError> { + unsupported(Capability::ToolMemory) + } + + async fn put_tool_rule(&self, _rule: ToolMemoryRule) -> Result<(), MemoryError> { + unsupported(Capability::ToolMemory) + } + + async fn delete_tool_rule( + &self, + _tool_name: &str, + _rule_id: &str, + ) -> Result { + unsupported(Capability::ToolMemory) + } +} + +#[async_trait] +impl MemorySourceSink for NullMemoryProvider { + async fn accept_source_items( + &self, + _source_id: &str, + _source_kind: &str, + _items: Vec, + _taint: MemoryTaint, + ) -> Result { + unsupported(Capability::Sources) + } + + async fn forget_source(&self, _source_id: &str) -> Result { + unsupported(Capability::Sources) + } +} + +#[async_trait] +impl MemoryMaintenance for NullMemoryProvider { + async fn reembed(&self) -> Result { + unsupported(Capability::Maintenance) + } + + async fn compact(&self) -> Result { + unsupported(Capability::Maintenance) + } + + async fn consolidate(&self) -> Result { + unsupported(Capability::Maintenance) + } + + async fn doctor(&self) -> Result { + unsupported(Capability::Maintenance) + } +} + +#[cfg(test)] +#[path = "null_tests.rs"] +mod tests; diff --git a/api/src/null_tests.rs b/api/src/null_tests.rs new file mode 100644 index 0000000..3d76219 --- /dev/null +++ b/api/src/null_tests.rs @@ -0,0 +1,242 @@ +//! Tests for the reference null driver. +//! +//! These pin three separate contracts: +//! +//! 1. the mandatory-three set is genuinely implementable without a store; +//! 2. an unadvertised family is **unreachable** through the trait object, which +//! is the degradation behaviour the kernel relies on; +//! 3. a direct call to an unadvertised family yields a typed `Unsupported` +//! error that **names** the family, which is what the transport adapter's +//! `501` mapping is checked against. +//! +//! ## No async runtime here, on purpose +//! +//! `tinymemory-api` must not depend on tokio (or any executor) — that is the +//! whole point of the crate. Every future in this module completes on its first +//! poll, so a six-line std-only [`block_on`] is sufficient and adds no +//! dependency. + +use std::future::Future; +use std::pin::pin; +use std::task::{Context, Poll}; + +use super::*; +use crate::provider::audit_provider; +use crate::types::MemoryCategory; + +/// Drive a future that is ready on first poll to completion, without an +/// executor. Panics rather than spinning if a future ever returns `Pending`, +/// because in this module that would mean a supposedly-inert implementation +/// started doing real work. +fn block_on(future: F) -> F::Output { + let mut future = pin!(future); + let mut context = Context::from_waker(std::task::Waker::noop()); + match future.as_mut().poll(&mut context) { + Poll::Ready(value) => value, + Poll::Pending => panic!("null driver future must complete on first poll"), + } +} + +#[test] +fn null_driver_advertises_exactly_the_mandatory_families() { + let driver = NullMemoryProvider::new(); + let capabilities = driver.capabilities(); + + assert_eq!(driver.driver_id(), NULL_DRIVER_ID); + assert_eq!(capabilities.len(), 3); + for capability in Capability::MANDATORY { + assert!( + capabilities.contains(capability), + "{capability} must be advertised" + ); + } +} + +#[test] +fn null_driver_passes_capability_validation() { + // The mandatory-three set is the minimum bindable set, so the reference + // driver must be bindable. If this ever fails, either the mandatory list + // grew or the null driver stopped implementing it. + let driver = NullMemoryProvider::new(); + assert_eq!(driver.capabilities().validate(), Ok(())); +} + +#[test] +fn null_driver_is_self_consistent() { + assert_eq!(audit_provider(&NullMemoryProvider::new()), Ok(())); +} + +#[test] +fn null_driver_reports_ready() { + let health = block_on(NullMemoryProvider::new().health()); + assert_eq!(health, MemoryHealth::Ready); + assert!(health.is_usable()); +} + +#[test] +fn null_driver_shutdown_is_an_idempotent_no_op() { + let driver = NullMemoryProvider::new(); + assert!(block_on(driver.shutdown()).is_ok()); + assert!(block_on(driver.shutdown()).is_ok()); +} + +#[test] +fn mandatory_core_accepts_writes_and_reads_back_empty() { + let driver = NullMemoryProvider::new(); + + block_on(driver.store( + "global", + "k", + "v", + MemoryCategory::Core, + None, + MemoryTaint::ExternalSync, + )) + .expect("null store must accept the write"); + + assert!(block_on(driver.get("global", "k")) + .expect("get must succeed") + .is_none()); + assert!(!block_on(driver.forget("global", "k")).expect("forget must succeed")); + assert!(block_on(driver.list(None, None, None)) + .expect("list must succeed") + .is_empty()); + assert!(block_on(driver.namespaces()) + .expect("namespaces must succeed") + .is_empty()); +} + +#[test] +fn mandatory_recall_returns_no_hits() { + let driver = NullMemoryProvider::new(); + let hits = block_on(driver.recall("anything", 10, &OwnedRecallOpts::default(), None)) + .expect("recall must succeed"); + assert!(hits.is_empty()); +} + +#[test] +fn mandatory_portability_round_trips_as_an_empty_store() { + let driver = NullMemoryProvider::new(); + + let page = block_on(driver.export_page(None, 100)).expect("export must succeed"); + assert!(page.records.is_empty()); + assert!( + page.next_cursor.is_none(), + "the absent cursor is what terminates the caller's export loop" + ); + + let outcome = block_on(driver.import_records(vec![ExportRecord { + kind: "entry".to_string(), + id: "rec-1".to_string(), + namespace: None, + taint: MemoryTaint::Internal, + payload: serde_json::Value::Null, + }])) + .expect("import must succeed"); + + // Skipped, never imported: reporting an import would tell a migration its + // data landed somewhere it did not. + assert_eq!(outcome.imported, 0); + assert_eq!(outcome.skipped, 1); + assert_eq!(outcome.failed, 0); +} + +#[test] +fn export_page_rejects_a_cursor_it_never_issued() { + let driver = NullMemoryProvider::new(); + + let err = block_on(driver.export_page(Some("unexpected"), 100)) + .expect_err("a cursor this driver never issued must be rejected, not silently accepted"); + assert!( + matches!(err, MemoryError::Invalid(_)), + "expected MemoryError::Invalid, got {err:?}" + ); +} + +#[test] +fn every_unadvertised_family_is_unreachable_through_the_trait_object() { + let driver = NullMemoryProvider::new(); + let provider: &dyn MemoryProvider = &driver; + + assert!(provider.as_ingest().is_none()); + assert!(provider.as_documents().is_none()); + assert!(provider.as_tree().is_none()); + assert!(provider.as_entities().is_none()); + assert!(provider.as_graph().is_none()); + assert!(provider.as_diff().is_none()); + assert!(provider.as_goals().is_none()); + assert!(provider.as_tool_memory().is_none()); + assert!(provider.as_sources().is_none()); + assert!(provider.as_maintenance().is_none()); +} + +#[test] +fn advertised_and_reachable_agree_for_every_family() { + // The invariant that keeps the capability set honest, checked family by + // family rather than only through the aggregate audit. + let driver = NullMemoryProvider::new(); + let provider: &dyn MemoryProvider = &driver; + let advertised = provider.capabilities(); + + for capability in Capability::ALL { + assert_eq!( + advertised.contains(capability), + provider.provides(capability), + "{capability}: advertised and reachable must agree" + ); + } +} + +/// Assert a result is `Unsupported` and names the expected family. +fn assert_unsupported(result: Result, expected: Capability) { + match result { + Err(MemoryError::Unsupported { capability }) => { + assert_eq!(capability, expected.as_str()); + } + other => panic!("expected Unsupported({expected}), got {other:?}"), + } +} + +#[test] +fn unadvertised_families_return_unsupported_naming_their_capability() { + let driver = NullMemoryProvider::new(); + + assert_unsupported(block_on(driver.ingest_chat(Vec::new())), Capability::Ingest); + assert_unsupported( + block_on(driver.get_document("global", "k")), + Capability::Documents, + ); + assert_unsupported(block_on(driver.seal("global")), Capability::Tree); + assert_unsupported( + block_on(driver.entities("global", None, 10)), + Capability::Entities, + ); + assert_unsupported(block_on(driver.kv_get(None, "k")), Capability::Graph); + assert_unsupported( + block_on(driver.capture_snapshot("src-abc")), + Capability::Diff, + ); + assert_unsupported(block_on(driver.goals()), Capability::Goals); + assert_unsupported(block_on(driver.tool_rules("shell")), Capability::ToolMemory); + assert_unsupported( + block_on(driver.forget_source("src-abc")), + Capability::Sources, + ); + assert_unsupported(block_on(driver.doctor()), Capability::Maintenance); +} + +#[test] +fn provider_is_usable_as_a_shared_trait_object() { + // The registry binds `Arc`, so the trait object must be + // `Send + Sync` and every family trait must be object-safe. This test fails + // to *compile* rather than to run if that ever regresses. + fn assert_send_sync(_value: &T) {} + + let provider: std::sync::Arc = + std::sync::Arc::new(NullMemoryProvider::new()); + assert_send_sync(&provider); + assert_eq!(provider.driver_id(), NULL_DRIVER_ID); + assert!(block_on(provider.list(None, None, None)) + .expect("list through the trait object") + .is_empty()); +} diff --git a/api/src/provider/audit.rs b/api/src/provider/audit.rs new file mode 100644 index 0000000..47d1a5b --- /dev/null +++ b/api/src/provider/audit.rs @@ -0,0 +1,132 @@ +//! The honesty check: does a driver's advertised capability set match the +//! surface it actually exposes? +//! +//! [`MemoryProvider::capabilities`] is a *claim*, and the kernel acts on it — +//! it registers RPC methods and assembles agent tools from the advertised set +//! and never re-checks. A driver that advertises a family it does not implement +//! therefore produces a surface that exists in `/schema`, appears in the agent's +//! tool list, and fails on first use. That is precisely the +//! "registered-but-failing" outcome the degradation design exists to avoid. +//! +//! [`audit_provider`] compares the claim against +//! [`MemoryProvider::provides`] — which is derived from the accessors, so it +//! cannot drift from reality — and reports both directions of mismatch. Run it +//! at bind time next to [`crate::capabilities::Capabilities::validate`], and in +//! every driver's own test suite. +//! +//! The two directions mean different things: +//! +//! - **Advertised but absent** is a bug that will surface as a failing call. It +//! should refuse the bind. +//! - **Present but unadvertised** is dead surface: the family works but the +//! kernel unregistered it, so nothing can reach it. Usually a forgotten +//! entry in the driver's `capabilities()` list. + +use std::fmt; + +use crate::capabilities::Capability; +use crate::error::MemoryError; +use crate::provider::driver::MemoryProvider; + +/// A disagreement between what a driver advertises and what it implements. +/// +/// Carries the families structurally rather than as a formatted string so a +/// caller can report them in a status payload or a bind-failure event as well +/// as in a log line. At least one of the two vectors is non-empty. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilityAudit { + /// Families the driver advertises but does not expose. These will fail on + /// first call; refuse the bind. + pub advertised_but_absent: Vec, + /// Families the driver exposes but does not advertise. These are + /// unreachable, because the kernel filters from the advertised set. + pub present_but_unadvertised: Vec, +} + +impl fmt::Display for CapabilityAudit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut parts = Vec::new(); + if !self.advertised_but_absent.is_empty() { + parts.push(format!( + "advertised but not implemented: {}", + join(&self.advertised_but_absent) + )); + } + if !self.present_but_unadvertised.is_empty() { + parts.push(format!( + "implemented but not advertised: {}", + join(&self.present_but_unadvertised) + )); + } + write!(f, "memory driver capability mismatch; {}", parts.join("; ")) + } +} + +impl std::error::Error for CapabilityAudit {} + +impl From for MemoryError { + /// A mismatch is the driver saying something untrue about itself, which is + /// a configuration/implementation error rather than an unsupported call — + /// hence [`MemoryError::Invalid`] and not + /// [`MemoryError::Unsupported`]. Same reasoning as + /// [`crate::capabilities::MissingMandatoryCapabilities`]. + fn from(value: CapabilityAudit) -> Self { + MemoryError::Invalid(value.to_string()) + } +} + +fn join(families: &[Capability]) -> String { + families + .iter() + .map(|cap| cap.as_str()) + .collect::>() + .join(", ") +} + +/// Compare a driver's advertised capability set against its reachable surface. +/// +/// Walks every [`Capability`] in declaration order, so the returned vectors are +/// in that order too. +/// +/// # Errors +/// +/// Returns [`CapabilityAudit`] when the two disagree in either direction. A +/// driver that agrees with itself returns `Ok(())`. +/// +/// # Examples +/// +/// ``` +/// # use tinymemory_api::null::NullMemoryProvider; +/// # use tinymemory_api::provider::audit_provider; +/// // The reference null driver is self-consistent. +/// assert!(audit_provider(&NullMemoryProvider::new()).is_ok()); +/// ``` +pub fn audit_provider(provider: &dyn MemoryProvider) -> Result<(), CapabilityAudit> { + let advertised = provider.capabilities(); + let mut advertised_but_absent = Vec::new(); + let mut present_but_unadvertised = Vec::new(); + + for capability in Capability::ALL { + match ( + advertised.contains(capability), + provider.provides(capability), + ) { + (true, false) => advertised_but_absent.push(capability), + (false, true) => present_but_unadvertised.push(capability), + _ => {} + } + } + + if advertised_but_absent.is_empty() && present_but_unadvertised.is_empty() { + Ok(()) + } else { + Err(CapabilityAudit { + advertised_but_absent, + present_but_unadvertised, + }) + } +} + +#[cfg(test)] +#[path = "audit_tests.rs"] +mod tests; diff --git a/api/src/provider/audit_tests.rs b/api/src/provider/audit_tests.rs new file mode 100644 index 0000000..07ec80e --- /dev/null +++ b/api/src/provider/audit_tests.rs @@ -0,0 +1,195 @@ +//! Tests for the advertised-vs-implemented honesty check. +//! +//! Two deliberately dishonest fixtures sit here — one that over-claims and one +//! that under-claims — because the whole value of [`audit_provider`] is +//! catching drivers that disagree with themselves, and neither direction is +//! reachable from an honest driver. + +use async_trait::async_trait; + +use super::*; +use crate::capabilities::Capabilities; +use crate::health::MemoryHealth; +use crate::null::NullMemoryProvider; +use crate::provider::types::{ExportPage, ExportRecord, ImportOutcome, SourceScope}; +use crate::provider::{MemoryCore, MemoryPortability, MemoryRecall, MemoryTree}; +use crate::recall::OwnedRecallOpts; +use crate::types::{MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary}; + +/// A provider that forwards the mandatory three to [`NullMemoryProvider`] so +/// each fixture below only has to describe the thing it is lying about. +struct Fixture { + inner: NullMemoryProvider, + advertised: Capabilities, + expose_tree: bool, +} + +impl Fixture { + fn new(advertised: Capabilities, expose_tree: bool) -> Self { + Self { + inner: NullMemoryProvider::new(), + advertised, + expose_tree, + } + } +} + +#[async_trait] +impl MemoryCore for Fixture { + async fn store( + &self, + namespace: &str, + key: &str, + content: &str, + category: MemoryCategory, + session_id: Option<&str>, + taint: MemoryTaint, + ) -> Result<(), MemoryError> { + self.inner + .store(namespace, key, content, category, session_id, taint) + .await + } + + async fn get(&self, namespace: &str, key: &str) -> Result, MemoryError> { + self.inner.get(namespace, key).await + } + + async fn forget(&self, namespace: &str, key: &str) -> Result { + self.inner.forget(namespace, key).await + } + + async fn list( + &self, + namespace: Option<&str>, + category: Option<&MemoryCategory>, + session_id: Option<&str>, + ) -> Result, MemoryError> { + self.inner.list(namespace, category, session_id).await + } + + async fn namespaces(&self) -> Result, MemoryError> { + self.inner.namespaces().await + } +} + +#[async_trait] +impl MemoryRecall for Fixture { + async fn recall( + &self, + query: &str, + limit: usize, + opts: &OwnedRecallOpts, + scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + self.inner.recall(query, limit, opts, scope).await + } +} + +#[async_trait] +impl MemoryPortability for Fixture { + async fn export_page( + &self, + cursor: Option<&str>, + limit: usize, + ) -> Result { + self.inner.export_page(cursor, limit).await + } + + async fn import_records( + &self, + records: Vec, + ) -> Result { + self.inner.import_records(records).await + } +} + +#[async_trait] +impl MemoryProvider for Fixture { + fn driver_id(&self) -> &str { + "fixture" + } + + fn capabilities(&self) -> Capabilities { + self.advertised + } + + async fn health(&self) -> MemoryHealth { + MemoryHealth::Ready + } + + fn as_tree(&self) -> Option<&dyn MemoryTree> { + if self.expose_tree { + Some(&self.inner) + } else { + None + } + } +} + +#[test] +fn honest_driver_passes_the_audit() { + let honest = Fixture::new(Capabilities::mandatory().with(Capability::Tree), true); + assert_eq!(audit_provider(&honest), Ok(())); +} + +#[test] +fn over_claiming_driver_is_reported_as_advertised_but_absent() { + // Advertises everything, exposes no optional accessor. Every one of the ten + // optional families would fail on first call — the exact + // registered-but-failing outcome the capability filter exists to prevent. + let liar = Fixture::new(Capabilities::all(), false); + + let audit = audit_provider(&liar).expect_err("over-claiming driver must fail the audit"); + assert_eq!(audit.present_but_unadvertised, Vec::new()); + assert_eq!(audit.advertised_but_absent.len(), 10); + assert!(audit.advertised_but_absent.contains(&Capability::Tree)); + // The mandatory three are supertraits, so they can never be missing. + assert!(!audit.advertised_but_absent.contains(&Capability::Core)); + assert!(!audit.advertised_but_absent.contains(&Capability::Recall)); + assert!(!audit + .advertised_but_absent + .contains(&Capability::Portability)); +} + +#[test] +fn under_claiming_driver_is_reported_as_present_but_unadvertised() { + // Implements the tree but forgot to list it: the family works and is + // completely unreachable, because the kernel filters from the advertised + // set. + let shy = Fixture::new(Capabilities::mandatory(), true); + + let audit = audit_provider(­).expect_err("under-claiming driver must fail the audit"); + assert_eq!(audit.advertised_but_absent, Vec::new()); + assert_eq!(audit.present_but_unadvertised, vec![Capability::Tree]); +} + +#[test] +fn audit_findings_are_reported_in_declaration_order() { + let liar = Fixture::new(Capabilities::all(), false); + let audit = audit_provider(&liar).expect_err("expected a mismatch"); + + let declaration_order: Vec = Capability::ALL + .into_iter() + .filter(|cap| audit.advertised_but_absent.contains(cap)) + .collect(); + assert_eq!(audit.advertised_but_absent, declaration_order); +} + +#[test] +fn audit_error_names_every_mismatched_family_and_maps_to_invalid() { + let liar = Fixture::new(Capabilities::all(), false); + let audit = audit_provider(&liar).expect_err("expected a mismatch"); + + let rendered = audit.to_string(); + for capability in &audit.advertised_but_absent { + assert!( + rendered.contains(capability.as_str()), + "audit message must name {capability}: {rendered}" + ); + } + + // A driver lying about itself is a config/implementation error, not an + // unsupported call. + let error: MemoryError = audit.into(); + assert!(matches!(error, MemoryError::Invalid(_))); +} diff --git a/api/src/provider/content.rs b/api/src/provider/content.rs new file mode 100644 index 0000000..9c16e86 --- /dev/null +++ b/api/src/provider/content.rs @@ -0,0 +1,167 @@ +//! Optional families that put content *into* memory and navigate it: +//! [`MemoryIngest`], [`MemoryDocuments`], and [`MemoryTree`]. +//! +//! All three are optional. A driver that advertises none of them is still a +//! memory backend — it just accepts entries only through +//! [`crate::provider::MemoryCore::store`] and has no document tier and no +//! summary tree. The kernel unregisters the matching RPC methods and omits the +//! matching agent tools rather than registering handlers that fail. +//! +//! ## No configuration crosses this boundary +//! +//! Chunk sizes, embedding models, summariser prompts, seal thresholds, and +//! cascade policy are all *driver* concerns. None of them appear in these +//! signatures: the embedded driver reads them from the `MemoryConfig` it +//! already holds, and an external driver has its own. This was the sharpest +//! test of whether the M0 crate carve-out drew the line in the right place — +//! the families that looked most config-dependent turned out not to need any. + +use async_trait::async_trait; + +use crate::chunks::Chunk; +use crate::error::MemoryError; +use crate::provider::types::{IngestItem, IngestOutcome, SourceScope}; +use crate::tree::{IngestRequest, QueryResult, TreeStatus}; +use crate::types::{NamespaceDocumentInput, NamespaceRetrievalContext, StoredMemoryDocument}; + +/// Bulk content ingestion — the driver owns chunking and embedding. +/// +/// The distinction from [`crate::provider::MemoryCore::store`] is ownership of +/// the pipeline: `store` persists exactly one entry the caller has already +/// shaped, whereas ingest hands over raw source material and lets the driver +/// decide how to split, embed, and index it. +#[async_trait] +pub trait MemoryIngest: Send + Sync { + /// Ingest one standalone document. + /// + /// # Errors + /// + /// [`MemoryError::Invalid`] for content the driver refuses (empty body, + /// unsupported MIME), otherwise backend failures. + async fn ingest_document(&self, item: IngestItem) -> Result; + + /// Ingest a run of chat messages that share a conversation. + /// + /// Taken as a batch rather than one call per message because chat chunking + /// is inherently cross-message: a driver needs neighbouring turns to decide + /// where a chunk boundary belongs. Ordering within `messages` is + /// significant and must be preserved by the caller. + /// + /// # Errors + /// + /// As [`Self::ingest_document`]. Partial success is reported through the + /// counts in [`IngestOutcome`], not as an error. + async fn ingest_chat(&self, messages: Vec) -> Result; +} + +/// The namespace-document tier: whole documents addressed by `(namespace, key)`. +/// +/// Distinct from [`crate::provider::MemoryCore`] in granularity and in what is +/// stored: entries are short facts, documents are bodies with titles, tags, +/// source types, and structured metadata, and they carry their own ranked query +/// surface. +#[async_trait] +pub trait MemoryDocuments: Send + Sync { + /// Upsert a document, returning its driver-assigned id. + /// + /// Keyed by `(namespace, key)` from the input: reusing a key replaces the + /// existing document rather than creating a second one. + /// + /// # Errors + /// + /// [`MemoryError::Invalid`] for a rejected input, otherwise backend + /// failures. + async fn put_document(&self, input: NamespaceDocumentInput) -> Result; + + /// Fetch a document by `(namespace, key)`. + /// + /// # Errors + /// + /// A missing document is `Ok(None)`; `Err` is reserved for backend + /// failures. + async fn get_document( + &self, + namespace: &str, + key: &str, + ) -> Result, MemoryError>; + + /// Run a ranked query over one namespace's documents. + /// + /// Returns both the ranked hits and the driver's rendered context text, so + /// a caller that only wants something injectable does not have to + /// re-assemble it (and re-assemble it differently from every other caller). + /// + /// # Errors + /// + /// Backend failures only; a query that matches nothing returns an empty + /// hit list. + async fn query_documents( + &self, + namespace: &str, + query: &str, + limit: usize, + ) -> Result; +} + +/// The time-ordered summary tree: buffered leaves rolled up into hour → day → +/// month → year → root summaries. +/// +/// Sealing and cascading are exposed as explicit calls rather than happening +/// implicitly on ingest because the **host** owns scheduling. A driver runs one +/// step when asked; it does not get to install its own background loop. This is +/// the same rule as the engine's `queue::run_once`. +#[async_trait] +pub trait MemoryTree: Send + Sync { + /// Append raw content to the ingestion buffer for later sealing. + /// + /// # Errors + /// + /// [`MemoryError::Invalid`] for a rejected request, otherwise backend + /// failures. + async fn append(&self, request: IngestRequest) -> Result<(), MemoryError>; + + /// Retrieve the chunks a single logical source contributed, newest first. + /// + /// `scope` is the per-turn allowlist and must be applied **inside** the + /// driver's query, for the reasons in [`SourceScope`]. `None` means + /// unrestricted. + /// + /// # Errors + /// + /// Backend failures only; an unknown `source_id` yields an empty vector. + async fn query_source( + &self, + namespace: &str, + source_id: &str, + limit: usize, + scope: Option<&SourceScope>, + ) -> Result, MemoryError>; + + /// Fetch one node together with its direct children, for navigation. + /// + /// # Errors + /// + /// [`MemoryError::NotFound`] when `node_id` does not exist in `namespace`. + async fn drill_down(&self, namespace: &str, node_id: &str) -> Result; + + /// Convert buffered content into leaf nodes, returning the resulting tree + /// state. + /// + /// Idempotent when the buffer is empty: sealing nothing is a successful + /// no-op, not an error, so a scheduler may call it unconditionally. + /// + /// # Errors + /// + /// Backend failures only. + async fn seal(&self, namespace: &str) -> Result; + + /// Roll sealed leaves up through the parent levels, returning the resulting + /// tree state. + /// + /// Idempotent for the same reason as [`Self::seal`]. + /// + /// # Errors + /// + /// Backend failures only. + async fn cascade(&self, namespace: &str) -> Result; +} diff --git a/api/src/provider/driver.rs b/api/src/provider/driver.rs new file mode 100644 index 0000000..29475a3 --- /dev/null +++ b/api/src/provider/driver.rs @@ -0,0 +1,197 @@ +//! [`MemoryProvider`] — the single trait a memory driver implements, and the +//! object the kernel binds. +//! +//! ## Self-contained on purpose +//! +//! `MemoryProvider` does **not** extend a host `Driver` trait and names no host +//! type. `tinymemory-api` is what a third-party driver compiles against, so it +//! must not drag in the OpenHuman host; and the generic subsystem vocabulary +//! (`Driver`, `DriverClass`, `SubsystemRegistry`, the policy `Guard`) belongs +//! kernel-side, where inference and channels can share it without importing a +//! *memory* crate. +//! +//! The bridge is the host's memory adapter, which implements the host `Driver` +//! for an `Arc` and converts [`MemoryHealth`] into the +//! kernel's `DriverHealth`. That conversion is trivial by construction — see +//! [`crate::health`]. +//! +//! Driver **class** (embedded / external / null) is deliberately absent from +//! this trait. Class is a fact about how the host bound a driver, recorded in +//! host configuration; a driver self-reporting it would let a misconfigured +//! external backend claim to be embedded and skip the egress and trust checks +//! that class gates. +//! +//! ## The accessor form, and why not `Any` +//! +//! The kernel binds `Arc` and needs per-family access. Two +//! designs were available: downcast through [`std::any::Any`], or one +//! `Option`-returning accessor per optional family. The accessors win: +//! +//! - **No unchecked downcast.** `Any` would require the caller to name a +//! concrete driver type, which defeats the point of binding behind a trait +//! object, or to register type ids, which is the same table with worse +//! ergonomics. +//! - **The capability set and the reachable surface stay provably in sync.** +//! [`crate::provider::audit_provider`] compares [`MemoryProvider::capabilities`] +//! against what the accessors actually return, so "advertised but not +//! implemented" is a detectable, testable mistake instead of a runtime +//! surprise on the first call. +//! - **[`MemoryProvider::provides`] is an exhaustive `match`** over +//! [`Capability`], so adding a family without wiring an accessor fails to +//! compile. +//! +//! The three mandatory families are supertraits rather than accessors, so they +//! are callable directly on the trait object and cannot be absent. +//! +//! ## Object safety +//! +//! Every method here and in every family trait is object-safe: no generic +//! parameters, no `Self` in return position, no associated constants. The +//! `#[async_trait]` attribute rewrites the `async fn`s into boxed futures, +//! which is what makes them dyn-compatible at all. + +use async_trait::async_trait; + +use crate::capabilities::{Capabilities, Capability}; +use crate::error::MemoryError; +use crate::health::MemoryHealth; +use crate::provider::content::{MemoryDocuments, MemoryIngest, MemoryTree}; +use crate::provider::knowledge::{MemoryDiff, MemoryEntities, MemoryGraph}; +use crate::provider::mandatory::{MemoryCore, MemoryPortability, MemoryRecall}; +use crate::provider::records::{ + MemoryGoals, MemoryMaintenance, MemorySourceSink, MemoryToolMemory, +}; + +/// A bound memory driver. +/// +/// Implementors must also implement the three mandatory families +/// ([`MemoryCore`], [`MemoryRecall`], [`MemoryPortability`]) — they are +/// supertraits, so a driver missing any of them cannot be constructed as a +/// provider at all. +/// +/// The ten optional families are reached through the `as_*` accessors below. +/// Each defaults to `None`, so a minimal driver implements only what it +/// supports and inherits correct absence for everything else. +#[async_trait] +pub trait MemoryProvider: MemoryCore + MemoryRecall + MemoryPortability + 'static { + /// Stable identifier for this driver (`tinycortex`, `supermemory`, `null`). + /// + /// Appears in status output, log lines, tracing spans, and audit events, so + /// it must be stable across restarts and must not embed a URL, a token, or + /// anything else user- or deployment-specific. + fn driver_id(&self) -> &str; + + /// The families this driver implements. + /// + /// Asked **once** at bind time and cached: the kernel filters RPC + /// registration and agent-tool assembly from the cached answer, so a set + /// that changes after binding will not be noticed. A driver whose surface + /// genuinely varies must report the union and answer + /// [`MemoryError::Unsupported`] for the gaps. + /// + /// Must be honest: every advertised family must be reachable through its + /// accessor. [`crate::provider::audit_provider`] checks exactly that. + fn capabilities(&self) -> Capabilities; + + /// Current liveness, as the driver reports it. + /// + /// Called on bind and on demand for status output. Implementations should + /// be cheap and must not block indefinitely — a health probe that hangs is + /// indistinguishable from a subsystem that is down, but takes a timeout to + /// find out. + async fn health(&self) -> MemoryHealth; + + /// Release resources ahead of process exit or a rebind. + /// + /// Defaults to a successful no-op, because most drivers have nothing to + /// release; a driver holding a connection pool or a background task should + /// override it. The host's adapter forwards its `Driver::shutdown` here. + /// + /// Must be idempotent: a rebind followed by process exit calls it twice. + /// + /// # Errors + /// + /// Backend failures during teardown. The caller logs and continues — + /// shutdown failure never blocks exit. + async fn shutdown(&self) -> Result<(), MemoryError> { + Ok(()) + } + + /// Bulk ingestion, when advertised. + fn as_ingest(&self) -> Option<&dyn MemoryIngest> { + None + } + + /// The namespace-document tier, when advertised. + fn as_documents(&self) -> Option<&dyn MemoryDocuments> { + None + } + + /// The summary tree, when advertised. + fn as_tree(&self) -> Option<&dyn MemoryTree> { + None + } + + /// The entity index, when advertised. + fn as_entities(&self) -> Option<&dyn MemoryEntities> { + None + } + + /// The key/value and relation graph, when advertised. + fn as_graph(&self) -> Option<&dyn MemoryGraph> { + None + } + + /// Snapshot and change tracking, when advertised. + fn as_diff(&self) -> Option<&dyn MemoryDiff> { + None + } + + /// The long-term goals document, when advertised. + fn as_goals(&self) -> Option<&dyn MemoryGoals> { + None + } + + /// Per-tool learned rules, when advertised. + fn as_tool_memory(&self) -> Option<&dyn MemoryToolMemory> { + None + } + + /// The host-sync write seam, when advertised. + fn as_sources(&self) -> Option<&dyn MemorySourceSink> { + None + } + + /// Scheduler-driven upkeep, when advertised. + fn as_maintenance(&self) -> Option<&dyn MemoryMaintenance> { + None + } + + /// Whether `capability` is actually **reachable** on this driver. + /// + /// This is the implementation-side truth, as opposed to + /// [`Self::capabilities`], which is the advertised claim. The two should + /// agree; [`crate::provider::audit_provider`] is where they are compared. + /// + /// The mandatory three are always `true` because they are supertraits. The + /// remaining ten delegate to their accessor. + /// + /// The `match` is deliberately exhaustive: [`Capability`] is not + /// `#[non_exhaustive]`, so adding a family without adding an accessor and + /// an arm here is a compile error rather than a silent `false`. + fn provides(&self, capability: Capability) -> bool { + match capability { + Capability::Core | Capability::Recall | Capability::Portability => true, + Capability::Ingest => self.as_ingest().is_some(), + Capability::Documents => self.as_documents().is_some(), + Capability::Tree => self.as_tree().is_some(), + Capability::Entities => self.as_entities().is_some(), + Capability::Graph => self.as_graph().is_some(), + Capability::Diff => self.as_diff().is_some(), + Capability::Goals => self.as_goals().is_some(), + Capability::ToolMemory => self.as_tool_memory().is_some(), + Capability::Sources => self.as_sources().is_some(), + Capability::Maintenance => self.as_maintenance().is_some(), + } + } +} diff --git a/api/src/provider/knowledge.rs b/api/src/provider/knowledge.rs new file mode 100644 index 0000000..45140e5 --- /dev/null +++ b/api/src/provider/knowledge.rs @@ -0,0 +1,176 @@ +//! Optional families that expose *derived structure* over stored memory: +//! [`MemoryEntities`], [`MemoryGraph`], and [`MemoryDiff`]. +//! +//! Each is independently optional. A driver may have a key/value graph but no +//! entity index, or track source snapshots without either. The kernel filters +//! RPC registration and agent-tool assembly per family, so an absent family is +//! invisible rather than present-and-failing. +//! +//! As in [`crate::provider::content`], no configuration crosses this boundary: +//! extraction models, hotness decay curves, and snapshot retention are driver +//! concerns and appear in none of these signatures. + +use async_trait::async_trait; + +use crate::error::MemoryError; +use crate::provider::types::{DiffReport, EntityHit, SnapshotRef}; +use crate::types::{GraphRelationRecord, MemoryKvRecord}; + +/// The entity index: who and what the stored memory is about. +#[async_trait] +pub trait MemoryEntities: Send + Sync { + /// List entities in a namespace, ranked by hotness when `query` is `None` + /// and by match quality otherwise. + /// + /// # Errors + /// + /// Backend failures only; an unknown namespace yields an empty vector. + async fn entities( + &self, + namespace: &str, + query: Option<&str>, + limit: usize, + ) -> Result, MemoryError>; + + /// Edges incident to one entity, most relevant first. + /// + /// Returns [`GraphRelationRecord`] — the same shape [`MemoryGraph`] uses — + /// so a caller that has both families does not have to reconcile two edge + /// representations. + /// + /// # Errors + /// + /// Backend failures only; an unknown `entity_id` yields an empty vector + /// rather than [`MemoryError::NotFound`], because "no edges" and "no such + /// entity" are the same answer to this question. + async fn entity_edges( + &self, + namespace: &str, + entity_id: &str, + limit: usize, + ) -> Result, MemoryError>; + + /// Record that these entities were just observed, updating hotness. + /// + /// Separate from the read path because hotness is a *write* the host + /// triggers at known moments (a turn referenced these entities), not + /// something a driver should infer from being queried — otherwise merely + /// browsing the index would reshape ranking. + /// + /// # Errors + /// + /// Backend failures only. Unknown ids are ignored, not rejected. + async fn touch_entities( + &self, + namespace: &str, + entity_ids: &[String], + ) -> Result<(), MemoryError>; +} + +/// The key/value and relation graph tier. +/// +/// `namespace` is `Option<&str>` throughout: `None` addresses the global, +/// namespace-less slice, matching the storage shape of +/// [`MemoryKvRecord::namespace`] and [`GraphRelationRecord::namespace`]. +#[async_trait] +pub trait MemoryGraph: Send + Sync { + /// Read one key/value record. + /// + /// # Errors + /// + /// A missing key is `Ok(None)`; `Err` is reserved for backend failures. + async fn kv_get( + &self, + namespace: Option<&str>, + key: &str, + ) -> Result, MemoryError>; + + /// Upsert one key/value record. + /// + /// # Errors + /// + /// [`MemoryError::Invalid`] for a rejected key, otherwise backend failures. + async fn kv_put( + &self, + namespace: Option<&str>, + key: &str, + value: serde_json::Value, + ) -> Result<(), MemoryError>; + + /// List key/value records, optionally restricted to a key prefix. + /// + /// # Errors + /// + /// Backend failures only. + async fn kv_list( + &self, + namespace: Option<&str>, + prefix: Option<&str>, + limit: usize, + ) -> Result, MemoryError>; + + /// Query relations, narrowing by subject and/or predicate. + /// + /// Both filters are `None`-able so one method covers "everything about this + /// subject", "every edge of this type", and "the whole slice", instead of + /// three near-identical methods. + /// + /// # Errors + /// + /// Backend failures only. + async fn relations( + &self, + namespace: Option<&str>, + subject: Option<&str>, + predicate: Option<&str>, + limit: usize, + ) -> Result, MemoryError>; + + /// Upsert one relation, keyed by `(namespace, subject, predicate, object)`. + /// + /// # Errors + /// + /// [`MemoryError::Invalid`] for a malformed edge, otherwise backend + /// failures. + async fn put_relation(&self, relation: GraphRelationRecord) -> Result<(), MemoryError>; +} + +/// Snapshot capture and change computation over synced sources. +#[async_trait] +pub trait MemoryDiff: Send + Sync { + /// Capture a snapshot of one source's current items. + /// + /// # Errors + /// + /// [`MemoryError::NotFound`] for an unknown `source_id`, otherwise backend + /// failures. + async fn capture_snapshot(&self, source_id: &str) -> Result; + + /// List snapshots for one source, newest first. + /// + /// # Errors + /// + /// Backend failures only; an unknown `source_id` yields an empty vector. + async fn snapshots( + &self, + source_id: &str, + limit: usize, + ) -> Result, MemoryError>; + + /// Compute the change set between two snapshots of one source. + /// + /// `from` is `Option<&str>` so the first-ever diff — where there is no + /// baseline and every item is an addition — is expressible without a + /// separate method or a sentinel id. + /// + /// # Errors + /// + /// [`MemoryError::NotFound`] when either snapshot id is unknown, otherwise + /// backend failures. + async fn diff( + &self, + source_id: &str, + from: Option<&str>, + to: &str, + ) -> Result; +} diff --git a/api/src/provider/mandatory.rs b/api/src/provider/mandatory.rs new file mode 100644 index 0000000..4827848 --- /dev/null +++ b/api/src/provider/mandatory.rs @@ -0,0 +1,184 @@ +//! The three mandatory capability families: [`MemoryCore`], [`MemoryRecall`], +//! and [`MemoryPortability`]. +//! +//! These are supertraits of [`crate::provider::MemoryProvider`], which is what +//! makes "mandatory" a *compile-time* fact rather than a runtime check: a type +//! that does not implement all three cannot be a provider at all, so there is +//! no way to bind a driver that is missing them. +//! +//! The other ten families are reached through `Option`-returning accessors on +//! the provider, so their absence is representable and their presence is not +//! assumed. See [`crate::provider::MemoryProvider`] for that half. +//! +//! ## Why every method returns [`MemoryError`] and not `anyhow::Error` +//! +//! The transport adapter must be able to turn a `501` from an out-of-process +//! driver into [`MemoryError::Unsupported`], and the kernel must be able to +//! tell "this driver cannot do that" apart from "this driver failed". An +//! `anyhow::Error` erases exactly that distinction. The engine's own +//! [`crate::traits::Memory`] trait keeps `anyhow::Result` — it is an internal +//! storage abstraction with existing implementors, not the driver contract. + +use async_trait::async_trait; + +use crate::error::MemoryError; +use crate::provider::types::{ExportPage, ExportRecord, ImportOutcome, SourceScope}; +use crate::recall::OwnedRecallOpts; +use crate::types::{MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary}; + +/// Store, read, and delete individual memory entries. **Mandatory.** +/// +/// This is the smallest surface that still makes something a memory backend: +/// without it there is nothing to recall from and nothing to export. +#[async_trait] +pub trait MemoryCore: Send + Sync { + /// Upsert an entry, keyed by `(namespace, key)`. + /// + /// ## Taint is an argument, never a decision + /// + /// Unlike the engine's [`crate::traits::Memory`], which has a `store` and a + /// separate `store_with_taint` whose default implementation silently drops + /// the taint, the contract has **one** store and it always takes a + /// [`MemoryTaint`]. Provenance is stamped by the host policy guard before + /// the call; a driver that could default it would be able to launder + /// externally-sourced content into internal-trust content, which is the + /// single failure mode the guard exists to prevent. + /// + /// # Errors + /// + /// [`MemoryError::Invalid`] for caller input the driver rejects, + /// [`MemoryError::Io`] or [`MemoryError::Other`] for backend failures. + async fn store( + &self, + namespace: &str, + key: &str, + content: &str, + category: MemoryCategory, + session_id: Option<&str>, + taint: MemoryTaint, + ) -> Result<(), MemoryError>; + + /// Fetch the entry for an exact `(namespace, key)`. + /// + /// # Errors + /// + /// A missing entry is `Ok(None)`, never an error; `Err` is reserved for + /// backend failures. + async fn get(&self, namespace: &str, key: &str) -> Result, MemoryError>; + + /// Delete the entry for `(namespace, key)`, reporting whether it existed. + /// + /// Idempotent: forgetting an absent key is `Ok(false)`, so callers may call + /// it unconditionally. + /// + /// # Errors + /// + /// Backend failures only. + async fn forget(&self, namespace: &str, key: &str) -> Result; + + /// List entries, narrowing by namespace, category, and session. + /// + /// Each `Some` filter narrows the result; all `None` lists everything the + /// driver holds. An empty result is `Ok(vec![])`. + /// + /// # Errors + /// + /// Backend failures only. + async fn list( + &self, + namespace: Option<&str>, + category: Option<&MemoryCategory>, + session_id: Option<&str>, + ) -> Result, MemoryError>; + + /// Enumerate namespaces with their aggregate counts, for discovery. + /// + /// # Errors + /// + /// Backend failures only. + async fn namespaces(&self) -> Result, MemoryError>; +} + +/// Ranked retrieval. **Mandatory.** +#[async_trait] +pub trait MemoryRecall: Send + Sync { + /// Return up to `limit` entries relevant to `query`, most relevant first. + /// + /// `opts` is the **owned** [`OwnedRecallOpts`], never the borrowed + /// `RecallOpts<'a>`: a lifetime parameter cannot travel through an + /// object-safe `#[async_trait]` method, and the borrowed form derives no + /// serde impls so it could never be a request body. An embedded driver + /// converts to the borrowed form at its own boundary, which is zero-copy. + /// + /// `scope` is the per-turn source allowlist and is a **query predicate the + /// driver must apply internally** — see [`SourceScope`] for why applying it + /// after the fact is wrong. `None` means unrestricted. + /// + /// An empty or non-matching `query` yields `Ok(vec![])`, not an error. + /// + /// # Errors + /// + /// [`MemoryError::Invalid`] for a malformed filter, otherwise backend + /// failures. + async fn recall( + &self, + query: &str, + limit: usize, + opts: &OwnedRecallOpts, + scope: Option<&SourceScope>, + ) -> Result, MemoryError>; +} + +/// Export and import the whole store. **Mandatory.** +/// +/// Mandatory because binding a memory backend without it is a one-way door: a +/// user who cannot export cannot leave. It is the capability that makes every +/// other binding reversible, which is also why the `mirror` migration driver is +/// expressible at all. +#[async_trait] +pub trait MemoryPortability: Send + Sync { + /// Read one page of the export, continuing from `cursor`. + /// + /// Pass `None` to start. The export is complete when the returned + /// [`ExportPage::next_cursor`] is `None` — an empty `records` vector is + /// **not** a terminator, because a driver may legitimately return an empty + /// page while skipping a range. + /// + /// `limit` is a request, not a guarantee; a driver may return fewer. + /// + /// ## Why pages and not a stream + /// + /// A `Stream` return type would either make the trait non-object-safe or + /// drag an async runtime into a crate that deliberately has none. Paging + /// keeps both properties and still bounds memory, with the caller choosing + /// the bound. + /// + /// # Errors + /// + /// [`MemoryError::Invalid`] for a cursor this driver did not issue, + /// otherwise backend failures. + async fn export_page( + &self, + cursor: Option<&str>, + limit: usize, + ) -> Result; + + /// Write a batch of previously-exported records. + /// + /// Records carry their own [`crate::types::MemoryTaint`]; an importing + /// driver must persist what it is given and must not re-stamp provenance. + /// + /// Partial success is normal and is reported in [`ImportOutcome`] rather + /// than as an error: a migration should not abort a million-record restore + /// because one record was malformed. + /// + /// # Errors + /// + /// Reserved for failures that make the whole batch meaningless (backend + /// unavailable, transaction aborted). Per-record rejection belongs in + /// [`ImportOutcome::failed`]. + async fn import_records( + &self, + records: Vec, + ) -> Result; +} diff --git a/api/src/provider/mod.rs b/api/src/provider/mod.rs new file mode 100644 index 0000000..5fe65c9 --- /dev/null +++ b/api/src/provider/mod.rs @@ -0,0 +1,73 @@ +//! The memory driver contract: [`MemoryProvider`] plus the thirteen capability +//! family traits a driver may implement. +//! +//! ## Shape +//! +//! ```text +//! MemoryProvider ── identity, capabilities, health, shutdown +//! : MemoryCore (mandatory — supertrait, always callable) +//! : MemoryRecall (mandatory — supertrait, always callable) +//! : MemoryPortability (mandatory — supertrait, always callable) +//! ├─ as_ingest() -> Option<&dyn MemoryIngest> +//! ├─ as_documents() -> Option<&dyn MemoryDocuments> +//! ├─ as_tree() -> Option<&dyn MemoryTree> +//! ├─ as_entities() -> Option<&dyn MemoryEntities> +//! ├─ as_graph() -> Option<&dyn MemoryGraph> +//! ├─ as_diff() -> Option<&dyn MemoryDiff> +//! ├─ as_goals() -> Option<&dyn MemoryGoals> +//! ├─ as_tool_memory() -> Option<&dyn MemoryToolMemory> +//! ├─ as_sources() -> Option<&dyn MemorySourceSink> +//! └─ as_maintenance() -> Option<&dyn MemoryMaintenance> +//! ``` +//! +//! The mandatory three are supertraits, so "mandatory" is enforced by the type +//! system rather than by a runtime check. The optional ten are accessors that +//! default to `None`, so absence is the default and presence is opt-in. +//! +//! ## Rules that bind every family +//! +//! 1. **Typed errors, always.** Every method returns +//! `Result<_, MemoryError>`. The transport adapter maps an out-of-process +//! `501` onto [`crate::error::MemoryError::Unsupported`], and the kernel +//! distinguishes "cannot" from "failed". `anyhow::Error` would erase that. +//! 2. **No configuration crosses the boundary.** Not one signature names a +//! config type. A driver holds its own configuration; the contract passes +//! domain arguments only. +//! 3. **No host types.** Nothing here names an OpenHuman type, so a +//! third-party driver depends on this crate alone. +//! 4. **The driver never assigns provenance.** [`crate::types::MemoryTaint`] is +//! an argument on every write path and a preserved field on every import. +//! 5. **The host owns the loop.** Sealing, cascading, maintenance, and source +//! sync are all "run one step when asked"; no driver installs a background +//! task or hooks the agent turn. +//! 6. **Object safety throughout.** No generics, no `Self` returns, no +//! associated constants — every family is usable as `&dyn`. +//! +//! ## Reference implementation +//! +//! [`crate::null::NullMemoryProvider`] implements all thirteen families: +//! `/dev/null` semantics for the mandatory three, and +//! [`crate::error::MemoryError::Unsupported`] for the other ten, which it does +//! not advertise. It is what a compiled-out or unconfigured memory subsystem +//! binds to, and it doubles as the proof that the mandatory set is +//! implementable without a storage engine. + +pub mod audit; +pub mod content; +pub mod driver; +pub mod knowledge; +pub mod mandatory; +pub mod records; +pub mod types; + +pub use audit::{audit_provider, CapabilityAudit}; +pub use content::{MemoryDocuments, MemoryIngest, MemoryTree}; +pub use driver::MemoryProvider; +pub use knowledge::{MemoryDiff, MemoryEntities, MemoryGraph}; +pub use mandatory::{MemoryCore, MemoryPortability, MemoryRecall}; +pub use records::{MemoryGoals, MemoryMaintenance, MemorySourceSink, MemoryToolMemory}; +pub use types::{ + ChangeKind, DiffReport, EntityHit, EntityRef, ExportPage, ExportRecord, ImportOutcome, + IngestItem, IngestOutcome, MaintenanceReport, SnapshotRef, SourceChange, SourceItem, + SourceScope, +}; diff --git a/api/src/provider/records.rs b/api/src/provider/records.rs new file mode 100644 index 0000000..064697f --- /dev/null +++ b/api/src/provider/records.rs @@ -0,0 +1,167 @@ +//! The remaining optional families: [`MemoryGoals`], [`MemoryToolMemory`], +//! [`MemorySourceSink`], and [`MemoryMaintenance`]. +//! +//! Goals and tool memory are small curated record sets the agent reads on +//! nearly every turn. The source sink is the seam the host's sync machinery +//! writes through. Maintenance is the seam the host's scheduler drives. +//! +//! ## The host keeps the loop; the driver runs one step +//! +//! [`MemorySourceSink`] receives already-fetched items — the host owns +//! credentials, OAuth, rate limits, and the schedule. [`MemoryMaintenance`] +//! exposes four operations the host's existing scheduler calls; no driver +//! installs a background task of its own. Both follow the same rule as the +//! engine's `queue::run_once`, and both are why a driver never needs to see +//! configuration or a keychain. + +use async_trait::async_trait; + +use crate::error::MemoryError; +use crate::goals::GoalsDoc; +use crate::provider::types::{IngestOutcome, MaintenanceReport, SourceItem}; +use crate::tool_memory::ToolMemoryRule; +use crate::types::MemoryTaint; + +/// The agent's long-term goals document. +#[async_trait] +pub trait MemoryGoals: Send + Sync { + /// Read the current goals document. + /// + /// A driver with no goals yet returns an empty [`GoalsDoc`], not + /// [`MemoryError::NotFound`] — "no goals" is a valid state, not a missing + /// record. + /// + /// # Errors + /// + /// Backend failures only. + async fn goals(&self) -> Result; + + /// Replace the goals document wholesale. + /// + /// Whole-document replacement rather than per-item add/edit/delete because + /// the validating mutation surface (PII and secret predicates) is **host** + /// policy: the host parses, validates, mutates, and hands back the result. + /// Exposing per-item mutation here would put that policy behind a trait a + /// third-party driver implements, where it could be skipped. + /// + /// # Errors + /// + /// [`MemoryError::Invalid`] for a document the driver refuses (e.g. over + /// its own item cap), otherwise backend failures. + async fn set_goals(&self, goals: GoalsDoc) -> Result<(), MemoryError>; +} + +/// Per-tool learned rules — durable guidance attached to a specific tool. +#[async_trait] +pub trait MemoryToolMemory: Send + Sync { + /// Rules for one tool, highest priority first. + /// + /// # Errors + /// + /// Backend failures only; a tool with no rules yields an empty vector. + async fn tool_rules(&self, tool_name: &str) -> Result, MemoryError>; + + /// Upsert one rule, keyed by [`ToolMemoryRule::id`]. + /// + /// # Errors + /// + /// [`MemoryError::Invalid`] for a malformed rule, otherwise backend + /// failures. + async fn put_tool_rule(&self, rule: ToolMemoryRule) -> Result<(), MemoryError>; + + /// Delete one rule, reporting whether it existed. + /// + /// Idempotent, like [`crate::provider::MemoryCore::forget`]. + /// + /// # Errors + /// + /// Backend failures only. + async fn delete_tool_rule(&self, tool_name: &str, rule_id: &str) -> Result; +} + +/// The write seam for host-driven source sync. +#[async_trait] +pub trait MemorySourceSink: Send + Sync { + /// Accept a batch of items the host fetched from one logical source. + /// + /// `taint` applies to the whole batch and is stamped by the host. Sync + /// paths ingesting third-party content pass + /// [`MemoryTaint::ExternalSync`]; the driver persists what it is given and + /// never assigns provenance itself. + /// + /// `source_kind` is a wire string (`folder`, `composio`, …) rather than an + /// enum because the set of source kinds is owned by the host's sync + /// machinery and grows without a contract change. + /// + /// # Errors + /// + /// [`MemoryError::Invalid`] for a rejected batch, otherwise backend + /// failures. Per-item outcomes are counted in [`IngestOutcome`]. + async fn accept_source_items( + &self, + source_id: &str, + source_kind: &str, + items: Vec, + taint: MemoryTaint, + ) -> Result; + + /// Drop everything the driver holds for one logical source, returning how + /// many units were removed. + /// + /// This is the disconnect path: when a user removes a source, its content + /// must leave memory. Idempotent — an unknown `source_id` returns `Ok(0)`. + /// + /// # Errors + /// + /// Backend failures only. + async fn forget_source(&self, source_id: &str) -> Result; +} + +/// Periodic upkeep the host's scheduler drives. +/// +/// All four operations must be safe to call repeatedly and safe to interrupt: +/// the scheduler may invoke them on a timer, and a desktop process can exit at +/// any point. A driver that cannot bound the work should do a slice per call +/// and report progress in [`MaintenanceReport`]. +#[async_trait] +pub trait MemoryMaintenance: Send + Sync { + /// Recompute embeddings for content whose embedding is missing or stale. + /// + /// # Errors + /// + /// Backend failures, or [`MemoryError::BudgetExceeded`] when an embedding + /// budget is exhausted mid-run. + async fn reembed(&self) -> Result; + + /// Reclaim space: vacuum indexes, drop tombstones, prune dead references. + /// + /// # Errors + /// + /// Backend failures only. + async fn compact(&self) -> Result; + + /// Merge and summarise accumulated memory — the "dream" pass. + /// + /// The embedded driver maps this onto its seal/cascade/reembed cycle; an + /// external driver maps it onto whatever it calls the same idea. The + /// contract deliberately does not specify the mechanism, only that it is + /// the operation a scheduler runs when the system is idle. + /// + /// # Errors + /// + /// Backend failures only. + async fn consolidate(&self) -> Result; + + /// Read-only integrity check. + /// + /// Reports findings in [`MaintenanceReport::findings`] and must change + /// nothing — [`MaintenanceReport::changed`] is always `0`. A driver that + /// repairs as it inspects should expose that as [`Self::compact`] instead, + /// so an operator can diagnose without mutating. + /// + /// # Errors + /// + /// Backend failures only. A *finding* is not an error: a store with + /// problems still returns `Ok` with the problems listed. + async fn doctor(&self) -> Result; +} diff --git a/api/src/provider/types.rs b/api/src/provider/types.rs new file mode 100644 index 0000000..feb420a --- /dev/null +++ b/api/src/provider/types.rs @@ -0,0 +1,393 @@ +//! Value types that exist only because the *driver contract* needs them. +//! +//! Everything here is inert data: serde-derived, dependency-light, and free of +//! any engine or host type. They are separated from [`crate::types`] because +//! that module carries the historical engine value types (which the engine +//! crate aliases back into `tinycortex::memory::types`), whereas these are new +//! shapes introduced by the provider contract itself. +//! +//! ## Why these types and not the engine's +//! +//! Several families the contract exposes (diff, entities, sources, +//! maintenance) have richer types inside the `tinycortex` engine — for example +//! `memory::diff::types::DiffResult`. Those types are *implementation* shapes: +//! they carry git commit SHAs, ledger paths, and engine-specific enums. A +//! third-party driver cannot produce them and must not be required to. +//! +//! So the contract defines the narrower shape a *caller* actually needs, with +//! wire strings deliberately identical to the engine's where they overlap +//! (`added`/`removed`/`modified`), so the embedded driver's conversion is a +//! field-for-field map rather than a translation. +//! +//! ## What is deliberately absent +//! +//! No type here names a configuration struct. `MemoryConfig` stayed engine-side +//! in the M0 carve-out and stays there: a driver holds its own configuration +//! and the contract passes only domain arguments. If a future method cannot be +//! expressed without configuration, that is a signal the family was designed +//! wrong, not that the contract should widen. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::chunks::{DataSource, SourceRef}; +use crate::types::MemoryTaint; + +/// A per-turn allowlist of memory sources, passed **into** the driver as a +/// query predicate. +/// +/// ## Why this is a parameter and not a post-filter +/// +/// The host computes a per-turn source allowlist from product policy. If that +/// allowlist were applied after the driver returned rows, a `limit` would be +/// consumed by rows the caller is not allowed to see — so a scoped query could +/// return fewer results than it should, or none at all, purely as an artefact +/// of filtering order. Worse, an out-of-process driver would have already been +/// handed a query it should never have answered in full. +/// +/// The predicate therefore travels with the call. `None` means unrestricted; +/// `Some(scope)` means the driver must apply it *inside* its query. +/// +/// ## Matching rule (fail-closed) +/// +/// [`SourceScope::allows_source_id`] encodes the embedded engine's SQL +/// semantics verbatim: a source-attributed id is in scope when it either equals +/// an allowed id outright, or begins with `mem_src:{allowed}:`. An **empty** +/// allow list therefore matches nothing — a scope that lists no sources denies +/// all source-attributed content rather than waving it through. +/// +/// Content that is not attributed to a memory source at all (no +/// `memory_sources` provenance) is outside this predicate's remit; the driver +/// decides that, exactly as the engine's SQL does today. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceScope { + /// Allowed memory-source identifiers. Empty denies all source-attributed + /// content. + pub allow: Vec, +} + +impl SourceScope { + /// Builds a scope from any iterator of source identifiers. + pub fn new(allow: impl IntoIterator>) -> Self { + Self { + allow: allow.into_iter().map(Into::into).collect(), + } + } + + /// Whether this scope lists no sources — in which case it denies all + /// source-attributed content. See the type docs for why that is the + /// fail-closed reading and not "unrestricted". + pub fn is_empty(&self) -> bool { + self.allow.is_empty() + } + + /// Whether `source_id` is in scope, using the engine's equality-or-prefix + /// rule. + /// + /// ``` + /// use tinymemory_api::provider::types::SourceScope; + /// + /// let scope = SourceScope::new(["src-abc"]); + /// assert!(scope.allows_source_id("src-abc")); + /// assert!(scope.allows_source_id("mem_src:src-abc:item-1")); + /// assert!(!scope.allows_source_id("src-xyz")); + /// + /// // An empty scope denies everything. + /// assert!(!SourceScope::default().allows_source_id("src-abc")); + /// ``` + pub fn allows_source_id(&self, source_id: &str) -> bool { + self.allow.iter().any(|allowed| { + source_id == allowed || source_id.starts_with(&format!("mem_src:{allowed}:")) + }) + } +} + +/// One unit of content handed to [`crate::provider::MemoryIngest`]. +/// +/// The driver owns chunking, embedding, and persistence — this type carries +/// only what the driver cannot know: where the content came from, when, who it +/// belongs to, and how far it may be trusted. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct IngestItem { + /// Target namespace; `None` means the driver's default namespace. + #[serde(default)] + pub namespace: Option, + /// Concrete upstream provider the content came from. + pub source: DataSource, + /// Stable logical id for the ingestion group (channel id, thread id, doc + /// id). This is the dedupe key, not a display value. + pub source_id: String, + /// Account or user the content belongs to; empty for anonymous/system + /// sources. + #[serde(default)] + pub owner: String, + /// Opaque pointer back to the raw source record, for citation and + /// drill-down. + #[serde(default)] + pub source_ref: Option, + /// The content itself, already decoded to text. + pub content: String, + /// MIME type of [`Self::content`] when the caller knows it. + #[serde(default)] + pub mime: Option, + /// Event time used for ordering and tree placement; the driver substitutes + /// ingest time when absent. + #[serde(default)] + pub timestamp: Option>, + /// Labels carried through from the source. Ingest does not interpret them. + #[serde(default)] + pub tags: Vec, + /// Provenance taint. The **host** stamps this; a driver must persist what it + /// is given and must never assign or upgrade it. + #[serde(default)] + pub taint: MemoryTaint, + /// Overrides `source_id` for on-disk path grouping only; `source_id` + /// remains the dedupe key. + #[serde(default)] + pub path_scope: Option, +} + +/// What an ingest call actually persisted. +/// +/// Counts rather than content, so the caller can report progress and detect a +/// silently-dropping driver without holding the written material in memory. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct IngestOutcome { + /// Units the driver newly persisted. + pub written: u32, + /// Units the driver recognised as already present and skipped. + pub skipped: u32, + /// Driver-assigned ids for the written units, when the driver exposes them. + /// May be empty even when [`Self::written`] is non-zero — an external + /// backend is not obliged to surface its internal ids. + #[serde(default)] + pub ids: Vec, +} + +/// One line of the portability stream. +/// +/// Export and import are defined over records rather than bytes so the contract +/// stays free of an async runtime and of any streaming abstraction: the host +/// adapter turns a page of records into NDJSON (and back) at the transport +/// boundary. +/// +/// [`Self::kind`] is a driver-defined string rather than an enum. A backend has +/// record kinds this crate has never heard of, and a migration between two +/// backends must round-trip them untouched rather than drop what it cannot +/// classify. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExportRecord { + /// Driver-defined record kind (e.g. `entry`, `document`, `chunk`). + pub kind: String, + /// Driver-assigned id, unique within [`Self::kind`]. + pub id: String, + /// Owning namespace, when the record has one. + #[serde(default)] + pub namespace: Option, + /// Provenance taint of the record's content. Preserved across + /// export → import; an importing driver must not re-stamp it. + #[serde(default)] + pub taint: MemoryTaint, + /// The record body, in the exporting driver's own shape. + pub payload: serde_json::Value, +} + +/// One page of an export, plus the cursor that continues it. +/// +/// Paging (rather than a stream) keeps [`crate::provider::MemoryPortability`] +/// object-safe and runtime-agnostic while still bounding memory: the caller +/// decides the page size and drives the loop. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ExportPage { + /// Records in this page. May be empty on the final page. + pub records: Vec, + /// Opaque cursor to pass to the next call. `None` means the export is + /// complete — this, not an empty [`Self::records`], is the terminator. + #[serde(default)] + pub next_cursor: Option, +} + +/// What an import call actually accepted. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportOutcome { + /// Records written. + pub imported: u32, + /// Records recognised as already present and skipped. + pub skipped: u32, + /// Records rejected. A non-zero value with an empty [`Self::errors`] is a + /// driver bug: a rejection the operator cannot diagnose. + pub failed: u32, + /// Operator-facing reasons for the failures, bounded by the driver. Must + /// not contain record content or credentials — this is logged. + #[serde(default)] + pub errors: Vec, +} + +/// Identity of an entity in the driver's index. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntityRef { + /// Canonical, driver-stable entity id. + pub id: String, + /// Entity kind as a wire string (`person`, `organization`, `topic`, …). + /// A string rather than an enum because the taxonomy is the driver's, and a + /// kind this build does not recognise must still round-trip. + pub kind: String, + /// Display name. + pub name: String, +} + +/// An entity together with its recency/frequency signals. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EntityHit { + /// The entity itself. + pub entity: EntityRef, + /// Driver-computed hotness, higher is hotter. Not normalised across + /// drivers — compare within one driver's results only. + pub hotness: f64, + /// Number of times the entity was observed. + pub mentions: u32, +} + +/// Identity of a captured snapshot. +/// +/// The engine's own snapshot type additionally carries the git commit SHA and +/// ledger trailers that back it; those are implementation, so the contract +/// exposes only the identity and the counts a caller can act on. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SnapshotRef { + /// Driver-stable snapshot id. + pub id: String, + /// Logical source this snapshot covers. + pub source_id: String, + /// Human-readable source label at capture time. + #[serde(default)] + pub label: String, + /// Number of items materialised into the snapshot. + pub item_count: u32, + /// Capture time in milliseconds since the Unix epoch. + pub taken_at_ms: i64, +} + +/// What happened to one item between two snapshots. +/// +/// Wire strings are identical to the engine's `memory::diff::types::ChangeKind` +/// so the embedded adapter maps rather than translates. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChangeKind { + /// Present in the later snapshot only. + Added, + /// Present in the earlier snapshot only. + Removed, + /// Present in both, with differing content. + Modified, +} + +impl ChangeKind { + /// Stable wire string. + pub fn as_str(self) -> &'static str { + match self { + Self::Added => "added", + Self::Removed => "removed", + Self::Modified => "modified", + } + } +} + +/// A single item-level change inside a [`DiffReport`]. +/// +/// Item identity is the item id, never the title, so a rename reports as a +/// removal plus an addition rather than a modification. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceChange { + /// Stable item id. + pub item_id: String, + /// Display title, or the id when the driver has no better label. + #[serde(default)] + pub title: String, + /// What kind of change occurred. + pub kind: ChangeKind, + /// Content hash on the earlier side; absent for an addition. + #[serde(default)] + pub old_content_hash: Option, + /// Content hash on the later side; absent for a removal. + #[serde(default)] + pub new_content_hash: Option, +} + +/// The result of diffing one source between two snapshots. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiffReport { + /// Source this diff covers. + pub source_id: String, + /// Baseline snapshot id; `None` for a first-ever diff, where everything is + /// an addition. + #[serde(default)] + pub from_snapshot_id: Option, + /// Target snapshot id. + pub to_snapshot_id: String, + /// Items added. + pub added: u32, + /// Items removed. + pub removed: u32, + /// Items modified. + pub modified: u32, + /// Items present and unchanged. + pub unchanged: u32, + /// Per-item changes. May be truncated by the driver; the counts above are + /// authoritative. + #[serde(default)] + pub changes: Vec, +} + +/// One item handed to [`crate::provider::MemorySourceSink`] by the host's sync +/// machinery. +/// +/// The host owns credentials, scheduling, and fetching; the driver owns storage +/// and indexing. This type is the whole of what crosses that line. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceItem { + /// Stable per-source item id. Dedupe key; not a display value. + pub item_id: String, + /// Display title. + #[serde(default)] + pub title: String, + /// Item body, already decoded to text. + pub content: String, + /// MIME type of [`Self::content`] when known. + #[serde(default)] + pub mime: Option, + /// Canonical URL back to the item, when it has one. + #[serde(default)] + pub url: Option, + /// Upstream last-modified time in milliseconds since the Unix epoch. + #[serde(default)] + pub updated_at_ms: Option, + /// Labels carried through from the source. + #[serde(default)] + pub tags: Vec, +} + +/// Outcome of one maintenance operation. +/// +/// A single shape covers reembed, compact, consolidate, and doctor because the +/// caller does the same thing with all four: report progress and surface +/// findings. A per-operation result type would multiply the contract surface +/// without giving any caller more to act on. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct MaintenanceReport { + /// Which operation ran (`reembed`, `compact`, `consolidate`, `doctor`). + pub operation: String, + /// Units the driver examined. + pub examined: u64, + /// Units the driver changed. Always `0` for `doctor`, which is read-only. + pub changed: u64, + /// Operator-facing findings and notes. Must not contain memory content or + /// credentials — this is logged and shown in status output. + #[serde(default)] + pub findings: Vec, +} + +#[cfg(test)] +#[path = "types_tests.rs"] +mod tests; diff --git a/api/src/provider/types_tests.rs b/api/src/provider/types_tests.rs new file mode 100644 index 0000000..390e59a --- /dev/null +++ b/api/src/provider/types_tests.rs @@ -0,0 +1,133 @@ +//! Tests for the contract-only value types. +//! +//! The focus is the two things a later slice can silently break: the +//! fail-closed reading of an empty [`SourceScope`], and the wire strings / +//! serde defaults that an out-of-process driver depends on. + +use super::*; + +#[test] +fn empty_source_scope_denies_every_source() { + let scope = SourceScope::default(); + assert!(scope.is_empty()); + assert!(!scope.allows_source_id("src-abc")); + assert!(!scope.allows_source_id("mem_src:src-abc:item")); +} + +#[test] +fn source_scope_matches_exact_id_and_mem_src_prefix() { + let scope = SourceScope::new(["src-abc", "src-def"]); + + assert!(scope.allows_source_id("src-abc")); + assert!(scope.allows_source_id("src-def")); + assert!(scope.allows_source_id("mem_src:src-abc:item-1")); + assert!(scope.allows_source_id("mem_src:src-def:nested:item")); + + assert!(!scope.allows_source_id("src-xyz")); + assert!(!scope.allows_source_id("mem_src:src-xyz:item-1")); +} + +#[test] +fn source_scope_prefix_requires_the_trailing_separator() { + // `src-abc` must not smear onto `src-abcdef`: the engine's SQL binds + // `mem_src:{id}:` including the trailing colon, so a longer id that merely + // starts with an allowed one is out of scope. + let scope = SourceScope::new(["src-abc"]); + assert!(!scope.allows_source_id("mem_src:src-abcdef:item")); + assert!(!scope.allows_source_id("src-abcdef")); +} + +#[test] +fn change_kind_wire_strings_match_the_engine() { + // These strings are shared with `memory::diff::types::ChangeKind`, so the + // embedded adapter maps rather than translates. Changing one is a contract + // major bump. + for (kind, expected) in [ + (ChangeKind::Added, "added"), + (ChangeKind::Removed, "removed"), + (ChangeKind::Modified, "modified"), + ] { + assert_eq!(kind.as_str(), expected); + assert_eq!( + serde_json::to_value(kind).expect("serialize change kind"), + serde_json::Value::String(expected.to_string()), + ); + } +} + +#[test] +fn export_page_terminates_on_absent_cursor_not_empty_records() { + let page = ExportPage::default(); + assert!(page.records.is_empty()); + assert!(page.next_cursor.is_none()); + + // An empty page with a cursor is a legitimate mid-export state, so callers + // must not treat "no records" as the terminator. + let midway = ExportPage { + records: Vec::new(), + next_cursor: Some("cursor-2".to_string()), + }; + assert!(midway.next_cursor.is_some()); +} + +#[test] +fn export_record_round_trips_taint_and_opaque_payload() { + let record = ExportRecord { + kind: "vendor_specific_kind".to_string(), + id: "rec-1".to_string(), + namespace: Some("global".to_string()), + taint: MemoryTaint::ExternalSync, + payload: serde_json::json!({ "anything": [1, 2, 3] }), + }; + + let json = serde_json::to_string(&record).expect("serialize record"); + let back: ExportRecord = serde_json::from_str(&json).expect("deserialize record"); + + assert_eq!(back, record); + assert_eq!(back.taint, MemoryTaint::ExternalSync); +} + +#[test] +fn ingest_item_deserializes_from_the_minimal_body() { + // Every optional field carries `#[serde(default)]`, so a caller that knows + // only source, id, and content can still build a valid request. + let item: IngestItem = serde_json::from_value(serde_json::json!({ + "source": "notion", + "source_id": "page-1", + "content": "hello", + })) + .expect("deserialize minimal ingest item"); + + assert_eq!(item.source, DataSource::Notion); + assert_eq!(item.namespace, None); + assert_eq!(item.owner, ""); + assert!(item.tags.is_empty()); + // Provenance defaults to the conservative-for-writes `Internal`; the host + // guard overrides it explicitly on every sync path. + assert_eq!(item.taint, MemoryTaint::Internal); +} + +#[test] +fn maintenance_report_defaults_to_a_clean_read_only_run() { + let report = MaintenanceReport { + operation: "doctor".to_string(), + ..MaintenanceReport::default() + }; + assert_eq!(report.changed, 0); + assert!(report.findings.is_empty()); +} + +#[test] +fn diff_report_expresses_a_first_ever_diff_without_a_sentinel() { + let report = DiffReport { + source_id: "src-abc".to_string(), + from_snapshot_id: None, + to_snapshot_id: "snap-1".to_string(), + added: 3, + ..DiffReport::default() + }; + + let json = serde_json::to_value(&report).expect("serialize diff report"); + assert_eq!(json["from_snapshot_id"], serde_json::Value::Null); + assert_eq!(json["added"], 3); +} diff --git a/api/src/recall.rs b/api/src/recall.rs new file mode 100644 index 0000000..4d82619 --- /dev/null +++ b/api/src/recall.rs @@ -0,0 +1,157 @@ +//! Recall filter contracts — the borrowed engine form and the owned +//! contract/wire form, kept side by side so they cannot drift. +//! +//! ## Why there are two +//! +//! [`RecallOpts`] is the historical, engine-facing shape: it borrows its string +//! filters so a hot retrieval path allocates nothing. That makes it unusable as +//! a contract type in two independent ways — it derives no serde impls, so it +//! cannot be a `POST /v1/memory/recall` request body, and its lifetime +//! parameter would have to be threaded through every `#[async_trait]` recall +//! method, which destroys the object safety the whole driver model rests on. +//! +//! [`OwnedRecallOpts`] is the answer: the same five fields, owned, serde- +//! derived. Contract and wire paths use the owned form; the engine path keeps +//! the borrowed one and converts at the boundary via +//! `RecallOpts::from(&owned)`, which is zero-copy for the string fields. +//! +//! ## Field parity is the contract +//! +//! A field added to one form and not the other is a silent contract hole: the +//! wire would accept a filter the engine never applies, or the engine would +//! offer a filter no remote driver can be told about. Two defences are in +//! place, and both must stay: +//! +//! 1. Both [`From`] impls **exhaustively destructure** their source, so adding +//! a field to either struct without handling it fails to compile. +//! 2. `owned_and_borrowed_recall_opts_have_identical_fields` in +//! `recall_tests.rs` round-trips a fully non-default value through both +//! directions, so a field that is merely *dropped* during conversion fails +//! the test. +//! +//! Both types live in this module (rather than in `types.rs`) precisely so the +//! pair is read and edited together. They are re-exported from +//! [`crate::types`], so every historical `types::RecallOpts` path — including +//! the engine crate's `tinycortex::memory::types::` alias — keeps resolving. + +use serde::{Deserialize, Serialize}; + +use crate::types::MemoryCategory; + +/// Optional filters for recall — the **borrowed, engine-facing** form. +/// +/// Borrows its string filters so an engine call path can pass slices of a +/// caller-owned request without allocating. It is deliberately *not* +/// serializable and deliberately *not* used in the driver contract: a lifetime +/// parameter cannot travel through an object-safe `#[async_trait]` method, and +/// a borrowed struct cannot be a request body. +/// +/// Use [`OwnedRecallOpts`] for anything that crosses a trait object or the +/// wire, and convert at the boundary with the [`From`] impl below. The two +/// types carry the same fields; a field added to one and not the other is a +/// silent contract hole, which +/// `owned_and_borrowed_recall_opts_have_identical_fields` exists to catch. +#[derive(Debug, Default, Clone)] +pub struct RecallOpts<'a> { + /// Restrict recall to this namespace; `None` falls back to [`crate::types::GLOBAL_NAMESPACE`]. + pub namespace: Option<&'a str>, + /// Restrict recall to entries of this category. + pub category: Option, + /// Restrict recall to entries scoped to this session. + pub session_id: Option<&'a str>, + /// Drop hits scoring below this threshold (typically 0.0–1.0). + pub min_score: Option, + /// When `true`, include conversational hits from other sessions in the same + /// workspace alongside the namespace recall. + pub cross_session: bool, +} + +/// Optional filters for recall — the **owned, contract-facing** form. +/// +/// This is the type the driver contract and the JSON wire protocol use. It +/// exists because [`RecallOpts`] cannot serve either role: +/// +/// - it derives no `Serialize`/`Deserialize`, so it cannot be a +/// `POST /v1/memory/recall` request body; +/// - it carries a borrow lifetime, which would have to be threaded through +/// every `#[async_trait]` recall method and destroys object safety at the +/// `dyn` boundary the whole driver model rests on. +/// +/// The borrowed form stays for engine-internal use so the embedded driver's +/// hot path allocates nothing: build the owned value once at the contract +/// boundary, then hand `RecallOpts::from(&owned)` down. +/// +/// Every field is `#[serde(default)]` so a minimal request body — even `{}` — +/// deserializes to the same value as [`Default::default`]. +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +pub struct OwnedRecallOpts { + /// Restrict recall to this namespace; `None` falls back to [`crate::types::GLOBAL_NAMESPACE`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Restrict recall to entries of this category. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + /// Restrict recall to entries scoped to this session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + /// Drop hits scoring below this threshold (typically 0.0–1.0). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_score: Option, + /// When `true`, include conversational hits from other sessions in the same + /// workspace alongside the namespace recall. + #[serde(default)] + pub cross_session: bool, +} + +impl<'a> From<&'a OwnedRecallOpts> for RecallOpts<'a> { + /// Borrows the owned form for an engine call. Zero-copy for the two string + /// fields; [`MemoryCategory`] is cloned because it owns a `String` in its + /// [`MemoryCategory::Custom`] variant and [`RecallOpts`] holds it by value. + /// + /// Exhaustively destructures the source so adding a field to + /// [`OwnedRecallOpts`] without handling it here is a compile error. + fn from(owned: &'a OwnedRecallOpts) -> Self { + let OwnedRecallOpts { + namespace, + category, + session_id, + min_score, + cross_session, + } = owned; + RecallOpts { + namespace: namespace.as_deref(), + category: category.clone(), + session_id: session_id.as_deref(), + min_score: *min_score, + cross_session: *cross_session, + } + } +} + +impl From> for OwnedRecallOpts { + /// Takes ownership of a borrowed form — the direction a transport adapter + /// needs when turning an engine-shaped call into a request body. + /// + /// Exhaustively destructures the source for the same reason as the inverse + /// impl. + fn from(borrowed: RecallOpts<'_>) -> Self { + let RecallOpts { + namespace, + category, + session_id, + min_score, + cross_session, + } = borrowed; + OwnedRecallOpts { + namespace: namespace.map(str::to_string), + category, + session_id: session_id.map(str::to_string), + min_score, + cross_session, + } + } +} + +#[cfg(test)] +#[path = "recall_tests.rs"] +mod tests; diff --git a/api/src/recall_tests.rs b/api/src/recall_tests.rs new file mode 100644 index 0000000..5adf317 --- /dev/null +++ b/api/src/recall_tests.rs @@ -0,0 +1,158 @@ +//! Unit tests for the recall filter contracts in [`super`]. +//! +//! The load-bearing test here is +//! `owned_and_borrowed_recall_opts_have_identical_fields`: it is the runtime +//! half of the field-parity defence described in the module docs (the compile +//! half being the exhaustive destructuring inside both `From` impls). + +use super::*; +use serde_json::json; + +/// Every field set to a non-default value, so a conversion that silently drops +/// one is visible. +fn fully_populated_owned() -> OwnedRecallOpts { + OwnedRecallOpts { + namespace: Some("projects".to_string()), + category: Some(MemoryCategory::Custom("field_notes".to_string())), + session_id: Some("session-42".to_string()), + min_score: Some(0.75), + cross_session: true, + } +} + +#[test] +fn owned_and_borrowed_recall_opts_have_identical_fields() { + let owned = fully_populated_owned(); + + // Owned → borrowed. Destructured exhaustively so a new field on + // `RecallOpts` fails to compile here rather than silently going unchecked. + let borrowed = RecallOpts::from(&owned); + let RecallOpts { + namespace, + category, + session_id, + min_score, + cross_session, + } = borrowed.clone(); + assert_eq!(namespace, Some("projects")); + assert_eq!(category, Some(MemoryCategory::Custom("field_notes".into()))); + assert_eq!(session_id, Some("session-42")); + assert_eq!(min_score, Some(0.75)); + assert!(cross_session); + + // Borrowed → owned, and back to the value we started from. A field dropped + // in either direction fails this equality. + let round_tripped = OwnedRecallOpts::from(borrowed); + assert_eq!(round_tripped, owned); +} + +#[test] +fn borrowed_view_is_zero_copy_over_the_owned_strings() { + let owned = fully_populated_owned(); + let borrowed = RecallOpts::from(&owned); + + // The borrowed form points *into* the owned value rather than at a copy; + // that is the whole reason the borrowed form survives. + assert_eq!( + borrowed.namespace.unwrap().as_ptr(), + owned.namespace.as_deref().unwrap().as_ptr() + ); + assert_eq!( + borrowed.session_id.unwrap().as_ptr(), + owned.session_id.as_deref().unwrap().as_ptr() + ); +} + +#[test] +fn owned_recall_opts_defaults_match_borrowed_defaults() { + let owned = OwnedRecallOpts::default(); + let borrowed = RecallOpts::from(&owned); + + assert!(borrowed.namespace.is_none()); + assert!(borrowed.category.is_none()); + assert!(borrowed.session_id.is_none()); + assert!(borrowed.min_score.is_none()); + assert!(!borrowed.cross_session); + + // And the borrowed default converts back to the owned default. + assert_eq!(OwnedRecallOpts::from(RecallOpts::default()), owned); +} + +#[test] +fn owned_recall_opts_serde_round_trips_every_field() { + let owned = fully_populated_owned(); + let encoded = serde_json::to_value(&owned).unwrap(); + + assert_eq!( + encoded, + json!({ + "namespace": "projects", + "category": "custom:field_notes", + "session_id": "session-42", + "min_score": 0.75, + "cross_session": true + }) + ); + + let decoded: OwnedRecallOpts = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, owned); +} + +#[test] +fn empty_recall_body_deserializes_to_the_default() { + // A minimal `POST /v1/memory/recall` body must be accepted: every field is + // `#[serde(default)]`. + let decoded: OwnedRecallOpts = serde_json::from_value(json!({})).unwrap(); + assert_eq!(decoded, OwnedRecallOpts::default()); +} + +#[test] +fn partial_recall_body_leaves_unmentioned_fields_at_default() { + let decoded: OwnedRecallOpts = + serde_json::from_value(json!({ "namespace": "global" })).unwrap(); + assert_eq!(decoded.namespace.as_deref(), Some("global")); + assert!(decoded.category.is_none()); + assert!(decoded.session_id.is_none()); + assert!(decoded.min_score.is_none()); + assert!(!decoded.cross_session); +} + +/// The wire form omits absent filters rather than emitting explicit nulls. +/// +/// `OwnedRecallOpts` is the body of `POST /v1/memory/recall`, which the spec +/// describes as an optional-filters bag. Emitting `"namespace": null` for every +/// unset filter is valid JSON but forces a backend to distinguish "absent" from +/// "explicitly null" for no gain. Pinned here because changing the emitted shape +/// after a driver has shipped is observable to any backend that draws that +/// distinction. +#[test] +fn absent_recall_filters_are_omitted_from_the_wire_form() { + let json = serde_json::to_value(OwnedRecallOpts::default()).expect("serialize"); + assert_eq!( + json, + serde_json::json!({ "cross_session": false }), + "unset optional filters must be omitted, not serialized as null" + ); + + let populated = OwnedRecallOpts { + namespace: Some("work".into()), + ..Default::default() + }; + let json = serde_json::to_value(&populated).expect("serialize"); + assert_eq!( + json, + serde_json::json!({ "namespace": "work", "cross_session": false }) + ); +} + +/// Omitting a filter and sending it as `null` must both decode to `None`, so a +/// backend built against either spelling keeps working. +#[test] +fn omitted_and_explicit_null_recall_filters_both_decode_to_none() { + let omitted: OwnedRecallOpts = serde_json::from_str("{}").expect("decode {}"); + let explicit: OwnedRecallOpts = + serde_json::from_str(r#"{"namespace":null,"category":null,"session_id":null,"min_score":null,"cross_session":false}"#) + .expect("decode explicit nulls"); + assert_eq!(omitted, explicit); + assert_eq!(omitted, OwnedRecallOpts::default()); +} diff --git a/api/src/tool_memory.rs b/api/src/tool_memory.rs new file mode 100644 index 0000000..8699d6d --- /dev/null +++ b/api/src/tool_memory.rs @@ -0,0 +1,162 @@ +//! Domain types for the tool-scoped memory layer. +//! +//! A [`ToolMemoryRule`] is a durable, actionable instruction attached to a +//! specific tool (e.g. `email`, `shell`, `web_search`). Unlike per-tool +//! effectiveness statistics, these rules capture **guidance** — corrections, +//! safety constraints, and learned operational rules that the agent should +//! obey when considering or invoking that tool. +//! +//! Rules carry a [`ToolMemoryPriority`] level so the retrieval pipeline can +//! distinguish safety-critical instructions from soft suggestions: +//! +//! - [`ToolMemoryPriority::Critical`] — pinned into the system prompt and +//! therefore not subject to mid-session context compression. +//! - [`ToolMemoryPriority::High`] — surfaced alongside critical rules at +//! tool-selection time. +//! - [`ToolMemoryPriority::Normal`] — available on demand via the recall +//! APIs, but not eagerly injected. +//! +//! These are pure data contracts: the snake_case wire strings +//! (`normal`/`high`/`critical`, `user_explicit`/`post_turn`/`programmatic`) +//! are preserved verbatim from OpenHuman so serialized rules stay +//! byte-compatible across the boundary. + +use serde::{Deserialize, Serialize}; + +/// Priority/criticality of a [`ToolMemoryRule`]. +/// +/// Used by both storage (to filter what is pinned into the system prompt) +/// and retrieval (to sort high-priority guidance ahead of advisory notes). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum ToolMemoryPriority { + /// Soft suggestion — surfaced on demand, not eagerly injected. + #[default] + Normal, + /// Important guidance — eagerly injected at tool-selection time. + High, + /// Safety-critical rule — pinned into the (compression-resistant) + /// system prompt so it survives the agent's full session. + Critical, +} + +impl ToolMemoryPriority { + /// True for priorities that must be eagerly surfaced to the agent + /// (Critical/High rules are both pinned into the system prompt and + /// prefetched at session start, so they survive context compression). + pub fn is_eager(self) -> bool { + matches!(self, Self::Critical | Self::High) + } +} + +/// Where a [`ToolMemoryRule`] originated from. +/// +/// Recorded for provenance and so consumers (UI / debugging) can tell user +/// edicts apart from auto-captured observations. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum ToolMemorySource { + /// User explicitly asked the agent to remember this rule. + UserExplicit, + /// Captured automatically from a post-turn observation (tool failure, + /// repeated correction, etc.). + PostTurn, + /// Written by another subsystem (e.g. an integration provisioner). + #[default] + Programmatic, +} + +/// A single tool-scoped memory rule. +/// +/// Stored under the `tool-{tool_name}` namespace as an entry keyed by +/// `rule/{rule_id}`. The id is stable across updates so callers can +/// upsert by replaying the same id. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolMemoryRule { + /// Stable identifier within `(tool_name)`. Generated by callers via + /// [`ToolMemoryRule::generate_id`] when one is not supplied. + pub id: String, + /// Tool this rule applies to (e.g. `email`, `shell`). + pub tool_name: String, + /// Natural-language guidance that should reach the agent. + pub rule: String, + /// Criticality level for retrieval and compression behaviour. + #[serde(default)] + pub priority: ToolMemoryPriority, + /// Where this rule came from. + #[serde(default)] + pub source: ToolMemorySource, + /// Optional free-form tags for filtering (e.g. `safety`, `permission`). + #[serde(default)] + pub tags: Vec, + /// RFC3339 timestamp of when the rule was first written. + pub created_at: String, + /// RFC3339 timestamp of the last update. + pub updated_at: String, +} + +impl ToolMemoryRule { + /// Build a new rule with a freshly generated id and `created_at` / + /// `updated_at` set to "now". + pub fn new( + tool_name: impl Into, + rule: impl Into, + priority: ToolMemoryPriority, + source: ToolMemorySource, + ) -> Self { + let now = chrono::Utc::now().to_rfc3339(); + Self { + id: Self::generate_id(), + tool_name: tool_name.into(), + rule: rule.into(), + priority, + source, + tags: Vec::new(), + created_at: now.clone(), + updated_at: now, + } + } + + /// Generate a fresh, opaque rule id. + /// + /// Each byte of a v4 UUID is encoded as two lowercase ASCII letters in + /// the `a..=p` range (one per nibble). The result is a separator-free, + /// digit-free token — deliberately shaped so it never trips a PII + /// boundary check when used as a storage key. + pub fn generate_id() -> String { + let mut id = String::with_capacity(33); + id.push('r'); + for byte in uuid::Uuid::new_v4().as_bytes() { + id.push((b'a' + (byte >> 4)) as char); + id.push((b'a' + (byte & 0x0f)) as char); + } + id + } + + /// Storage key used inside the tool namespace. + pub fn storage_key(id: &str) -> String { + format!("rule/{id}") + } +} + +/// Namespace string for a given tool. Trimmed and lower-cased so callers +/// can pass user-supplied tool names without leaking whitespace into +/// downstream queries. +/// +/// The `tool-` prefix is intentionally distinct from `global`, `skill-…` +/// and `tool_effectiveness` so retrieval and clearing operations can +/// reason about the namespace without ambiguity. Always build the +/// namespace through this helper — never hard-code the `tool-` format. +/// +/// The engine crate's `ToolMemoryStore::put_rule` applies the same +/// normalization to the stored rule so namespace and display/grouping identity +/// cannot diverge. +pub fn tool_memory_namespace(tool_name: &str) -> String { + format!("tool-{}", tool_name.trim().to_lowercase()) +} + +#[cfg(test)] +#[path = "tool_memory_tests.rs"] +mod tests; diff --git a/api/src/tool_memory_tests.rs b/api/src/tool_memory_tests.rs new file mode 100644 index 0000000..821369e --- /dev/null +++ b/api/src/tool_memory_tests.rs @@ -0,0 +1,128 @@ +//! Tests for the tool-scoped memory domain types. + +use super::*; + +#[test] +fn priority_default_is_normal() { + assert_eq!(ToolMemoryPriority::default(), ToolMemoryPriority::Normal); +} + +#[test] +fn priority_ordering_puts_critical_above_high() { + assert!(ToolMemoryPriority::Critical > ToolMemoryPriority::High); + assert!(ToolMemoryPriority::High > ToolMemoryPriority::Normal); +} + +#[test] +fn priority_is_eager_for_high_and_critical_only() { + assert!(ToolMemoryPriority::Critical.is_eager()); + assert!(ToolMemoryPriority::High.is_eager()); + assert!(!ToolMemoryPriority::Normal.is_eager()); +} + +#[test] +fn priority_snake_case_serde() { + assert_eq!( + serde_json::to_string(&ToolMemoryPriority::Critical).unwrap(), + "\"critical\"" + ); + assert_eq!( + serde_json::to_string(&ToolMemoryPriority::Normal).unwrap(), + "\"normal\"" + ); +} + +#[test] +fn source_snake_case_serde() { + assert_eq!( + serde_json::to_string(&ToolMemorySource::UserExplicit).unwrap(), + "\"user_explicit\"" + ); + assert_eq!( + serde_json::to_string(&ToolMemorySource::PostTurn).unwrap(), + "\"post_turn\"" + ); + assert_eq!( + serde_json::to_string(&ToolMemorySource::Programmatic).unwrap(), + "\"programmatic\"" + ); +} + +#[test] +fn source_default_is_programmatic() { + assert_eq!(ToolMemorySource::default(), ToolMemorySource::Programmatic); +} + +#[test] +fn rule_new_fills_id_and_timestamps() { + let rule = ToolMemoryRule::new( + "email", + "never email Sarah", + ToolMemoryPriority::Critical, + ToolMemorySource::UserExplicit, + ); + assert!(!rule.id.is_empty()); + assert_eq!(rule.tool_name, "email"); + assert_eq!(rule.rule, "never email Sarah"); + assert_eq!(rule.priority, ToolMemoryPriority::Critical); + assert_eq!(rule.source, ToolMemorySource::UserExplicit); + assert!(rule.created_at == rule.updated_at); +} + +#[test] +fn rule_generate_id_produces_unique_values() { + let a = ToolMemoryRule::generate_id(); + let b = ToolMemoryRule::generate_id(); + assert_ne!(a, b); + assert!(a.starts_with('r')); + assert!(a[1..].chars().all(|c| matches!(c, 'a'..='p'))); +} + +#[test] +fn generated_rule_ids_are_safe_memory_document_keys() { + // Generated ids must be free of digits and separators so the resulting + // storage key never resembles PII (phone numbers, ids, etc.) to a + // boundary check downstream. + for _ in 0..128 { + let id = ToolMemoryRule::generate_id(); + assert!( + id.chars().all(|ch| ch.is_ascii_lowercase()), + "generated id should avoid PII-shaped digits and separators: {id}" + ); + let key = ToolMemoryRule::storage_key(&id); + assert!( + key.bytes().all(|b| b == b'/' || b.is_ascii_lowercase()), + "generated storage key should not contain PII-shaped bytes: {key}" + ); + } +} + +#[test] +fn rule_storage_key_uses_rule_prefix() { + assert_eq!(ToolMemoryRule::storage_key("abc"), "rule/abc"); +} + +#[test] +fn rule_serde_roundtrip_preserves_fields() { + let rule = ToolMemoryRule { + id: "id-1".into(), + tool_name: "shell".into(), + rule: "never run sudo".into(), + priority: ToolMemoryPriority::High, + source: ToolMemorySource::PostTurn, + tags: vec!["safety".into()], + created_at: "2026-05-11T00:00:00Z".into(), + updated_at: "2026-05-11T00:00:01Z".into(), + }; + let json = serde_json::to_string(&rule).unwrap(); + let back: ToolMemoryRule = serde_json::from_str(&json).unwrap(); + assert_eq!(back, rule); +} + +#[test] +fn namespace_uses_tool_prefix_and_trims_whitespace() { + assert_eq!(tool_memory_namespace("email"), "tool-email"); + assert_eq!(tool_memory_namespace(" shell "), "tool-shell"); + assert_eq!(tool_memory_namespace("Send_Email"), "tool-send_email"); + assert_eq!(tool_memory_namespace("WebSearch"), "tool-websearch"); +} diff --git a/api/src/traits.rs b/api/src/traits.rs new file mode 100644 index 0000000..c96e0b9 --- /dev/null +++ b/api/src/traits.rs @@ -0,0 +1,157 @@ +//! The high-level [`Memory`] trait every storage backend implements. +//! +//! Ported from OpenHuman's `memory::traits`. Backend-specific escape hatches +//! (e.g. raw SQLite connection access) are intentionally omitted here so the +//! trait stays storage-agnostic; concrete backends expose those via their own +//! inherent methods. +//! +//! ## Contract notes +//! +//! - Every method returns `anyhow::Result<_>` rather than a typed error: this +//! trait is a stable abstraction boundary over heterogeneous backends +//! (SQLite, vector DB, in-memory, …), each with its own error domain, so +//! callers should treat a returned `Err` as opaque and log/propagate it +//! rather than match on its variant. Concrete backends document their own +//! failure modes (e.g. IO errors, malformed persisted rows) alongside their +//! inherent methods. +//! - None of these methods are specified to panic; a conforming implementation +//! should convert failures (invalid input, backend errors, poisoned locks) +//! into `Err` instead. +//! - [`Memory::store`] and [`Memory::store_with_taint`] are upserts keyed by +//! `(namespace, key)`: calling them again with the same key replaces the +//! prior entry rather than erroring or duplicating it. + +use async_trait::async_trait; + +use super::types::{MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary, RecallOpts}; + +/// The core trait for memory storage and retrieval. +/// +/// Any persistence backend (SQLite, Postgres, vector DB, in-memory, …) should +/// implement this to participate in a TinyMemory-backed memory engine. +#[async_trait] +pub trait Memory: Send + Sync { + /// Returns the backend name (e.g. `"sqlite"`, `"vector"`, `"in_memory"`). + fn name(&self) -> &str; + + /// Stores a new memory entry or updates an existing one. + /// + /// Idempotent upsert keyed by `(namespace, key)`: calling this again with + /// the same `namespace`/`key` replaces the previous `content`, `category`, + /// and `session_id` rather than erroring or creating a duplicate. Entries + /// stored this way carry [`MemoryTaint::Internal`] (the default); use + /// [`Self::store_with_taint`] to persist content from an external source. + /// + /// # Errors + /// + /// Returns `Err` on any backend failure (IO, serialization, connection + /// loss); implementations must not panic on caller-controlled input. + async fn store( + &self, + namespace: &str, + key: &str, + content: &str, + category: MemoryCategory, + session_id: Option<&str>, + ) -> anyhow::Result<()>; + + /// Store an entry with explicit provenance taint. + /// + /// Sync paths ingesting third-party text MUST use this with + /// [`MemoryTaint::ExternalSync`]. The default implementation degrades to + /// [`Self::store`] for backends that do not yet persist taint — meaning it + /// silently drops the `taint` argument for any backend that has not + /// overridden this method. Backends whose durability/policy story depends + /// on taint being recorded MUST override this method rather than rely on + /// the default. + async fn store_with_taint( + &self, + namespace: &str, + key: &str, + content: &str, + category: MemoryCategory, + session_id: Option<&str>, + taint: MemoryTaint, + ) -> anyhow::Result<()> { + if taint != MemoryTaint::Internal { + anyhow::bail!("backend does not support taint-preserving storage"); + } + self.store(namespace, key, content, category, session_id) + .await + } + + /// Recalls memories matching a query using keyword or semantic search. + /// + /// `limit` caps the number of returned entries; `opts` narrows the search + /// by namespace, category, session, minimum score, and cross-session + /// inclusion (see [`RecallOpts`]). An empty or non-matching `query` should + /// yield `Ok(vec![])`, not an error. Result ordering is backend-defined + /// (typically most-relevant first) but callers must not assume a stable + /// order across backends. + async fn recall( + &self, + query: &str, + limit: usize, + opts: RecallOpts<'_>, + ) -> anyhow::Result>; + + /// Recall documents whose *vector* similarity alone meets a threshold. + /// + /// Returns `(key, content)` pairs, most-relevant first. Defaults to empty so + /// keyword-only / mock backends opt out; a backend that overrides this + /// should treat `min_vector_similarity` as an inclusive floor (hits scoring + /// strictly below it are dropped) and `limit` as a hard cap on the + /// returned count. + async fn recall_relevant_by_vector( + &self, + namespace: &str, + query: &str, + limit: usize, + min_vector_similarity: f64, + ) -> anyhow::Result> { + let _ = (namespace, query, limit, min_vector_similarity); + Ok(Vec::new()) + } + + /// Retrieves a specific entry by exact `(namespace, key)`. + /// + /// Returns `Ok(None)` — not `Err` — when no entry exists for the pair; + /// `Err` is reserved for backend failures. + async fn get(&self, namespace: &str, key: &str) -> anyhow::Result>; + + /// Lists entries, optionally scoped by namespace, category, and session. + /// + /// Each `Option` filter narrows the result set when `Some`; passing all + /// three as `None` lists every entry the backend holds. An empty result + /// set is `Ok(vec![])`, never an error. + async fn list( + &self, + namespace: Option<&str>, + category: Option<&MemoryCategory>, + session_id: Option<&str>, + ) -> anyhow::Result>; + + /// Deletes the entry for `(namespace, key)`. Returns whether it existed. + /// + /// Idempotent: forgetting an already-absent `(namespace, key)` returns + /// `Ok(false)` rather than erroring, so callers may call this + /// unconditionally without checking existence first. + async fn forget(&self, namespace: &str, key: &str) -> anyhow::Result; + + /// Lists all namespaces with aggregate stats for agent-side discovery. + /// + /// See [`NamespaceSummary`] for the per-namespace count and + /// last-updated timestamp returned. + async fn namespace_summaries(&self) -> anyhow::Result>; + + /// Total count of all entries in the backend, across all namespaces. + async fn count(&self) -> anyhow::Result; + + /// Health check on the underlying storage system. + /// + /// Returns `true` when the backend is reachable and able to serve + /// requests. Unlike the other methods this reports failure as `false` + /// rather than `Err`, so it is safe to call from a liveness probe without + /// error-handling boilerplate. + async fn health_check(&self) -> bool; +} diff --git a/api/src/tree.rs b/api/src/tree.rs new file mode 100644 index 0000000..b6c6bd5 --- /dev/null +++ b/api/src/tree.rs @@ -0,0 +1,212 @@ +//! Domain types for the markdown time-based summary tree. +//! +//! Organises summaries as a time hierarchy: root → year → month → day → hour +//! (leaf). Ported from OpenHuman's `memory_tree/tree_runtime/types.rs`. + +use chrono::{DateTime, Datelike, Timelike, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Hierarchical level of a tree node. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NodeLevel { + /// Single tree root; aggregates all years. Wire string `"root"`. + Root, + /// One node per calendar year. Wire string `"year"`. + Year, + /// One node per calendar month. Wire string `"month"`. + Month, + /// One node per calendar day. Wire string `"day"`. + Day, + /// Leaf level; one node per hour, where raw content lands. Wire string `"hour"`. + Hour, +} + +impl NodeLevel { + /// Maximum number of tokens allowed at this level. + pub fn max_tokens(&self) -> u32 { + match self { + Self::Hour => 1_000, + Self::Day => 2_000, + Self::Month => 4_000, + Self::Year => 8_000, + Self::Root => 20_000, + } + } + + /// The level above this one in the hierarchy (`None` for root). + pub fn parent_level(&self) -> Option { + match self { + Self::Hour => Some(Self::Day), + Self::Day => Some(Self::Month), + Self::Month => Some(Self::Year), + Self::Year => Some(Self::Root), + Self::Root => None, + } + } + + /// True only for the leaf level (hour). + pub fn is_leaf(&self) -> bool { + matches!(self, Self::Hour) + } + + /// Parse a level string from YAML frontmatter. + pub fn from_str_label(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "root" => Some(Self::Root), + "year" => Some(Self::Year), + "month" => Some(Self::Month), + "day" => Some(Self::Day), + "hour" => Some(Self::Hour), + _ => None, + } + } + + /// Label for display / frontmatter. + pub fn as_str(&self) -> &'static str { + match self { + Self::Root => "root", + Self::Year => "year", + Self::Month => "month", + Self::Day => "day", + Self::Hour => "hour", + } + } +} + +/// A single node in the summary tree. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TreeNode { + /// Path-style hierarchical id, e.g. `"2024/03/15/09"` or `"root"`. + pub node_id: String, + /// Namespace owning this tree (isolates independent trees). + pub namespace: String, + /// Hierarchical level this node sits at. + pub level: NodeLevel, + /// Id of the parent node; `None` only for the root. + pub parent_id: Option, + /// Rolled-up summary text for this node. + pub summary: String, + /// Estimated token count of [`Self::summary`]; bounded by [`NodeLevel::max_tokens`]. + pub token_count: u32, + /// Number of direct children rolled into this node. + pub child_count: u32, + /// Creation timestamp (UTC). + pub created_at: DateTime, + /// Last-update timestamp (UTC). + pub updated_at: DateTime, + /// Optional opaque metadata blob; omitted from serialization when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +/// Metadata about an entire tree within a namespace. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TreeStatus { + /// Namespace the tree belongs to. + pub namespace: String, + /// Total number of nodes across all levels. + pub total_nodes: u64, + /// Number of populated levels (tree height). + pub depth: u32, + /// Timestamp of the earliest ingested entry, if any. + pub oldest_entry: Option>, + /// Timestamp of the most recent ingested entry, if any. + pub newest_entry: Option>, + /// When the tree was last (re)built or sealed. + pub last_run_at: Option>, +} + +/// Input for appending raw content to the ingestion buffer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IngestRequest { + /// Target namespace to append content into. + pub namespace: String, + /// Raw content to buffer for summarization. + pub content: String, + /// Event time used to derive the hour leaf; defaults to ingestion time when absent. + #[serde(default)] + pub timestamp: Option>, + /// Optional structured metadata carried alongside the content. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +/// Result of a tree query at a specific node. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryResult { + /// The node addressed by the query. + pub node: TreeNode, + /// Direct children of [`Self::node`], for drill-down navigation. + pub children: Vec, +} + +/// Rough token estimate: ~4 characters per token. +pub fn estimate_tokens(text: &str) -> u32 { + u32::try_from(text.len().div_ceil(4)).unwrap_or(u32::MAX) +} + +/// Derive the parent node ID from a node ID. +pub fn derive_parent_id(node_id: &str) -> Option { + if node_id == "root" { + return None; + } + match node_id.rfind('/') { + Some(pos) => Some(node_id[..pos].to_string()), + None => Some("root".to_string()), + } +} + +/// Determine the `NodeLevel` from a node ID string. +pub fn level_from_node_id(node_id: &str) -> NodeLevel { + if node_id == "root" { + return NodeLevel::Root; + } + match node_id.matches('/').count() { + 0 => NodeLevel::Year, + 1 => NodeLevel::Month, + 2 => NodeLevel::Day, + _ => NodeLevel::Hour, + } +} + +/// Derive all ancestor node IDs from a timestamp (hour through root). +/// Returns `(hour_id, day_id, month_id, year_id, root_id)`. +pub fn derive_node_ids(ts: &DateTime) -> (String, String, String, String, String) { + let year = format!("{}", ts.year()); + let month = format!("{}/{:02}", ts.year(), ts.month()); + let day = format!("{}/{:02}/{:02}", ts.year(), ts.month(), ts.day()); + let hour = format!( + "{}/{:02}/{:02}/{:02}", + ts.year(), + ts.month(), + ts.day(), + ts.hour() + ); + (hour, day, month, year, "root".to_string()) +} + +/// Convert a node ID to a relative file path within the tree directory. +pub fn node_id_to_path(node_id: &str) -> PathBuf { + if node_id == "root" { + return PathBuf::from("root.md"); + } + if node_id.starts_with('/') + || node_id + .split('/') + .any(|part| part.is_empty() || !part.chars().all(|c| c.is_ascii_digit())) + { + return PathBuf::from("invalid"); + } + let level = level_from_node_id(node_id); + if level.is_leaf() { + PathBuf::from(format!("{node_id}.md")) + } else { + PathBuf::from(node_id).join("summary.md") + } +} + +#[cfg(test)] +#[path = "tree_tests.rs"] +mod tests; diff --git a/api/src/tree_tests.rs b/api/src/tree_tests.rs new file mode 100644 index 0000000..bb9965f --- /dev/null +++ b/api/src/tree_tests.rs @@ -0,0 +1,86 @@ +//! Tests for the markdown time-tree node types. + +use super::*; +use chrono::TimeZone; +use std::path::PathBuf; + +#[test] +fn node_level_max_tokens() { + assert_eq!(NodeLevel::Hour.max_tokens(), 1_000); + assert_eq!(NodeLevel::Day.max_tokens(), 2_000); + assert_eq!(NodeLevel::Month.max_tokens(), 4_000); + assert_eq!(NodeLevel::Year.max_tokens(), 8_000); + assert_eq!(NodeLevel::Root.max_tokens(), 20_000); +} + +#[test] +fn node_level_parent_chain() { + assert_eq!(NodeLevel::Hour.parent_level(), Some(NodeLevel::Day)); + assert_eq!(NodeLevel::Day.parent_level(), Some(NodeLevel::Month)); + assert_eq!(NodeLevel::Month.parent_level(), Some(NodeLevel::Year)); + assert_eq!(NodeLevel::Year.parent_level(), Some(NodeLevel::Root)); + assert_eq!(NodeLevel::Root.parent_level(), None); +} + +#[test] +fn derive_parent_id_chain() { + assert_eq!(derive_parent_id("2024/03/15/14"), Some("2024/03/15".into())); + assert_eq!(derive_parent_id("2024/03/15"), Some("2024/03".into())); + assert_eq!(derive_parent_id("2024/03"), Some("2024".into())); + assert_eq!(derive_parent_id("2024"), Some("root".into())); + assert_eq!(derive_parent_id("root"), None); +} + +#[test] +fn level_from_node_id_all_levels() { + assert_eq!(level_from_node_id("root"), NodeLevel::Root); + assert_eq!(level_from_node_id("2024"), NodeLevel::Year); + assert_eq!(level_from_node_id("2024/03"), NodeLevel::Month); + assert_eq!(level_from_node_id("2024/03/15"), NodeLevel::Day); + assert_eq!(level_from_node_id("2024/03/15/14"), NodeLevel::Hour); +} + +#[test] +fn derive_node_ids_from_timestamp() { + let ts = Utc.with_ymd_and_hms(2024, 3, 15, 14, 30, 0).unwrap(); + let (hour, day, month, year, root) = derive_node_ids(&ts); + assert_eq!(hour, "2024/03/15/14"); + assert_eq!(day, "2024/03/15"); + assert_eq!(month, "2024/03"); + assert_eq!(year, "2024"); + assert_eq!(root, "root"); +} + +#[test] +fn node_id_to_path_mapping() { + assert_eq!(node_id_to_path("root"), PathBuf::from("root.md")); + assert_eq!(node_id_to_path("2024"), PathBuf::from("2024/summary.md")); + assert_eq!( + node_id_to_path("2024/03"), + PathBuf::from("2024/03/summary.md") + ); + assert_eq!( + node_id_to_path("2024/03/15/14"), + PathBuf::from("2024/03/15/14.md") + ); +} + +#[test] +fn estimate_tokens_rough() { + assert_eq!(estimate_tokens(""), 0); + assert_eq!(estimate_tokens("abcd"), 1); + assert_eq!(estimate_tokens(&"a".repeat(4000)), 1000); +} + +#[test] +fn node_level_roundtrip() { + for level in [ + NodeLevel::Root, + NodeLevel::Year, + NodeLevel::Month, + NodeLevel::Day, + NodeLevel::Hour, + ] { + assert_eq!(NodeLevel::from_str_label(level.as_str()), Some(level)); + } +} diff --git a/api/src/types.rs b/api/src/types.rs new file mode 100644 index 0000000..d6c066a --- /dev/null +++ b/api/src/types.rs @@ -0,0 +1,436 @@ +//! Core public data contracts for the TinyMemory memory contract. +//! +//! These types are the stable surface shared across every layer (storage, +//! ingestion, retrieval, RPC). They are pure data — no storage side effects, +//! no interior mutability, freely `Clone`/`Send`/`Sync` — and are ported +//! faithfully from OpenHuman's `memory` and `memory_store` modules so wire +//! formats (snake_case enum strings, serde defaults) stay byte-compatible when +//! OpenHuman imports this crate. +//! +//! ## Wire-compatibility contract +//! +//! Every `#[serde(rename_all = "snake_case")]` enum here has its variant +//! strings persisted in on-disk indexes (SQLite columns, markdown frontmatter) +//! and/or sent over the RPC boundary. Renaming a variant, or a struct field +//! that lacks `#[serde(default)]`, is a breaking change for any host reading +//! previously-written data. When adding a field, prefer `#[serde(default)]` so +//! older persisted rows continue to deserialize. +//! +//! ## Fail-closed provenance +//! +//! [`MemoryTaint`] is the one field in this module with a safety-relevant +//! default: it decodes unknown/corrupt persisted strings as +//! [`MemoryTaint::ExternalSync`] rather than [`MemoryTaint::Internal`], so a +//! caller that forgets to persist taint, or an index that has drifted, fails +//! toward *more* restrictive tool-use policy rather than less. + +use serde::{Deserialize, Serialize}; + +/// The recall filter contracts live in [`crate::recall`] so the borrowed and +/// owned forms sit next to each other and cannot drift, and are re-exported +/// here so every historical `types::RecallOpts` path — including the engine +/// crate's `tinycortex::memory::types::` alias — keeps resolving unchanged. +pub use crate::recall::{OwnedRecallOpts, RecallOpts}; + +/// Default namespace used when a caller passes no explicit namespace. +pub const GLOBAL_NAMESPACE: &str = "global"; + +/// Provenance / trust signal attached to a memory entry. +/// +/// Drives downstream policy — most importantly whether automation whose context +/// contains this content may invoke external-effect tools. Defaults to +/// [`MemoryTaint::Internal`] so legacy rows (no persisted taint column) and all +/// in-memory defaults are conservatively trusted as user-driven content. +/// +/// Sync paths that ingest text from third-party services (Gmail / Slack / +/// Notion / Composio / MCP / …) MUST set this to [`MemoryTaint::ExternalSync`] +/// at write time so callers can refuse external-effect tools on tainted context. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MemoryTaint { + /// User-driven memory (chat, manual remember, internal heuristics). + #[default] + Internal, + /// Content ingested from an external sync source. + ExternalSync, +} + +impl Serialize for MemoryTaint { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_db_str()) + } +} + +impl<'de> Deserialize<'de> for MemoryTaint { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Ok(Self::from_db_str(&raw)) + } +} + +impl MemoryTaint { + /// Serialised form used by the SQLite `memory_docs.taint` column. + /// + /// # Examples + /// + /// ``` + /// use tinymemory_api::types::MemoryTaint; + /// + /// assert_eq!(MemoryTaint::Internal.as_db_str(), "internal"); + /// assert_eq!(MemoryTaint::ExternalSync.as_db_str(), "external_sync"); + /// ``` + pub fn as_db_str(&self) -> &'static str { + match self { + Self::Internal => "internal", + Self::ExternalSync => "external_sync", + } + } + + /// Reverse of [`Self::as_db_str`]. Unknown values fail closed to the more + /// restrictive [`MemoryTaint::ExternalSync`] so policy gates refuse + /// external-effect tools on content of unknown provenance. + /// + /// Note this is *not* a strict inverse of [`Self::as_db_str`]: it never + /// errors, so a malformed or unexpected `raw` string (empty, wrong case, + /// truncated by a partial write, …) silently maps to + /// [`MemoryTaint::ExternalSync`] rather than surfacing as a parse failure. + /// + /// # Examples + /// + /// ``` + /// use tinymemory_api::types::MemoryTaint; + /// + /// assert_eq!(MemoryTaint::from_db_str("internal"), MemoryTaint::Internal); + /// assert_eq!(MemoryTaint::from_db_str("external_sync"), MemoryTaint::ExternalSync); + /// // Unrecognised input fails closed rather than erroring. + /// assert_eq!(MemoryTaint::from_db_str("garbage"), MemoryTaint::ExternalSync); + /// ``` + pub fn from_db_str(raw: &str) -> Self { + match raw { + "internal" => Self::Internal, + "external_sync" => Self::ExternalSync, + _ => Self::ExternalSync, + } + } +} + +/// Categories used to organize and filter memories by nature and lifecycle. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MemoryCategory { + /// Long-term foundational facts, user preferences, permanent decisions. + Core, + /// Temporal logs reflecting daily activities or ephemeral state. + Daily, + /// Contextual information derived from active conversations. + Conversation, + /// A user- or system-defined custom category. + Custom(String), +} + +/// The stable wire/display representation uses the built-in labels directly +/// and prefixes custom values with `custom:`. The prefix keeps +/// `Custom("core")` distinct from [`MemoryCategory::Core`] and makes Display, +/// serde, and [`std::str::FromStr`] true inverses. +impl std::fmt::Display for MemoryCategory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Core => write!(f, "core"), + Self::Daily => write!(f, "daily"), + Self::Conversation => write!(f, "conversation"), + Self::Custom(name) => write!(f, "custom:{name}"), + } + } +} + +impl std::str::FromStr for MemoryCategory { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "core" => Ok(Self::Core), + "daily" => Ok(Self::Daily), + "conversation" => Ok(Self::Conversation), + "custom:" => Ok(Self::Custom(String::new())), + value if value.starts_with("custom:") && value.len() > "custom:".len() => { + Ok(Self::Custom(value["custom:".len()..].to_string())) + } + value if !value.is_empty() => Ok(Self::Custom(value.to_string())), + _ => Err(format!("unknown memory category: {value}")), + } + } +} + +impl Serialize for MemoryCategory { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for MemoryCategory { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(serde::de::Error::custom) + } +} + +/// A single stored memory entry with associated metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryEntry { + /// Unique identifier (usually a UUID). + pub id: String, + /// Key or title associated with this memory. + pub key: String, + /// Actual content / value of the memory. + pub content: String, + /// Optional namespace for logical separation. + #[serde(default)] + pub namespace: Option, + /// Organizational category. + pub category: MemoryCategory, + /// ISO 8601 timestamp of create / last-update. + pub timestamp: String, + /// Optional session scope. + pub session_id: Option, + /// Optional relevance / confidence score (typically 0.0–1.0). + pub score: Option, + /// Provenance taint (see [`MemoryTaint`]). Absent on legacy JSON, in which + /// case it defaults to [`MemoryTaint::Internal`]; unknown persisted string + /// values decode as [`MemoryTaint::ExternalSync`]. + #[serde(default)] + pub taint: MemoryTaint, +} + +/// Summary row for agent-side namespace discovery. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NamespaceSummary { + /// Namespace identifier. + pub namespace: String, + /// Number of memory entries currently stored in the namespace. + pub count: usize, + /// RFC3339 timestamp of the most recent update in the namespace, if any. + pub last_updated: Option, +} + +/// Input payload for upserting a namespace-scoped memory document. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NamespaceDocumentInput { + /// Target namespace for the document. + pub namespace: String, + /// Stable upsert key; reusing a key updates the existing document. + pub key: String, + /// Human-readable title. + pub title: String, + /// Document body. + pub content: String, + /// Origin of the content (e.g. `chat`, `gmail`, `notion`). + pub source_type: String, + /// Caller-defined priority label. + pub priority: String, + /// Free-form tags for filtering. + #[serde(default)] + pub tags: Vec, + /// Arbitrary structured metadata carried alongside the document. + #[serde(default)] + pub metadata: serde_json::Value, + /// Category label (see [`MemoryCategory`] wire strings). + pub category: String, + /// Optional session scope. + #[serde(default)] + pub session_id: Option, + /// Explicit document id; generated when absent. + #[serde(default)] + pub document_id: Option, + /// Provenance taint; defaults to [`MemoryTaint::Internal`] for legacy JSON + /// missing this field. Unknown persisted string values decode as + /// [`MemoryTaint::ExternalSync`]. + #[serde(default)] + pub taint: MemoryTaint, +} + +/// One ranked retrieval result for a namespace text query. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NamespaceQueryResult { + /// Upsert key of the matched document. + pub key: String, + /// Matched content. + pub content: String, + /// Relevance score for this hit. + pub score: f64, + /// Category label of the matched document. + pub category: String, + /// Provenance taint; unknown persisted values decode as `external_sync`. + #[serde(default)] + pub taint: MemoryTaint, +} + +/// Discriminator for the kind of stored memory item a hit refers to. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemoryItemKind { + /// A namespace-scoped memory document (`memory_docs` row). + Document, + /// A key/value record. + Kv, + /// An episodic / conversational memory. + Episodic, + /// A discrete event entry. + Event, +} + +/// Persisted form of a memory document as stored in `memory_docs`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredMemoryDocument { + /// Unique document id. + pub document_id: String, + /// Owning namespace. + pub namespace: String, + /// Stable upsert key. + pub key: String, + /// Human-readable title. + pub title: String, + /// Document body. + pub content: String, + /// Origin of the content (e.g. `chat`, `gmail`). + pub source_type: String, + /// Caller-defined priority label. + pub priority: String, + /// Free-form tags. + pub tags: Vec, + /// Arbitrary structured metadata. + pub metadata: serde_json::Value, + /// Category label. + pub category: String, + /// Optional session scope. + pub session_id: Option, + /// Creation time as a Unix timestamp (seconds). + pub created_at: f64, + /// Last-update time as a Unix timestamp (seconds). + pub updated_at: f64, + /// Path, relative to the vault root, of the authoritative markdown file. + pub markdown_rel_path: String, + /// Provenance taint; unknown persisted values decode as `external_sync`. + #[serde(default)] + pub taint: MemoryTaint, +} + +/// A single KV row, namespace-scoped or global (when `namespace` is `None`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryKvRecord { + /// Owning namespace, or `None` for a global row. + pub namespace: Option, + /// KV key. + pub key: String, + /// Stored JSON value. + pub value: serde_json::Value, + /// Last-update time as a Unix timestamp (seconds). + pub updated_at: f64, +} + +/// A graph edge (subject — predicate → object) plus accumulated evidence. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphRelationRecord { + /// Owning namespace, or `None` for a global relation. + pub namespace: Option, + /// Edge subject (head entity). + pub subject: String, + /// Relation type linking subject to object. + pub predicate: String, + /// Edge object (tail entity). + pub object: String, + /// Arbitrary structured attributes attached to the edge. + pub attrs: serde_json::Value, + /// Last-update time as a Unix timestamp (seconds). + pub updated_at: f64, + /// Number of independent observations supporting this edge. + pub evidence_count: u32, + /// Optional ordering hint among sibling relations. + pub order_index: Option, + /// Documents that contributed evidence for this edge. + pub document_ids: Vec, + /// Chunks that contributed evidence for this edge. + pub chunk_ids: Vec, +} + +/// Per-signal contribution to a hit's final score, for ranking explainers. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RetrievalScoreBreakdown { + /// Lexical / keyword match contribution. + pub keyword_relevance: f64, + /// Vector (cosine) similarity contribution. + pub vector_similarity: f64, + /// Graph-proximity contribution. + pub graph_relevance: f64, + /// Episodic-recall contribution. + pub episodic_relevance: f64, + /// Recency contribution. + pub freshness: f64, + /// Weighted combination of the above signals; the value used for ranking. + pub final_score: f64, +} + +/// A single ranked retrieval hit. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NamespaceMemoryHit { + /// Identifier of the matched item (interpretation depends on [`Self::kind`]). + pub id: String, + /// Which kind of stored item this hit refers to. + pub kind: MemoryItemKind, + /// Owning namespace. + pub namespace: String, + /// Upsert key of the matched item. + pub key: String, + /// Title, when the item has one. + pub title: Option, + /// Matched content. + pub content: String, + /// Category label. + pub category: String, + /// Origin of the content, when known. + pub source_type: Option, + /// Last-update time as a Unix timestamp (seconds). + pub updated_at: f64, + /// Final ranking score; mirrors [`RetrievalScoreBreakdown::final_score`]. + pub score: f64, + /// Per-signal explanation of how [`Self::score`] was derived. + pub score_breakdown: RetrievalScoreBreakdown, + /// Source document id, when the hit resolves to a document. + #[serde(default)] + pub document_id: Option, + /// Source chunk id, when the hit resolves to a chunk. + #[serde(default)] + pub chunk_id: Option, + /// Graph relations that reinforced this hit's ranking. + #[serde(default)] + pub supporting_relations: Vec, + /// Provenance taint; unknown persisted values decode as `external_sync`. + #[serde(default)] + pub taint: MemoryTaint, +} + +/// Aggregated retrieval result for a namespace: rendered context plus hits. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NamespaceRetrievalContext { + /// Namespace the retrieval ran against. + pub namespace: String, + /// Originating query text, if any. + pub query: Option, + /// Rendered, ready-to-inject context assembled from [`Self::hits`]. + pub context_text: String, + /// Ranked hits backing the rendered context. + pub hits: Vec, +} + +#[cfg(test)] +#[path = "types_tests.rs"] +mod tests; diff --git a/api/src/types_tests.rs b/api/src/types_tests.rs new file mode 100644 index 0000000..5ee61b5 --- /dev/null +++ b/api/src/types_tests.rs @@ -0,0 +1,234 @@ +//! Unit tests for the core memory data contracts in [`super`]. + +use super::*; +use serde_json::json; + +#[test] +fn global_namespace_constant_is_stable() { + assert_eq!(GLOBAL_NAMESPACE, "global"); +} + +#[test] +fn memory_category_display_outputs_expected_values() { + assert_eq!(MemoryCategory::Core.to_string(), "core"); + assert_eq!(MemoryCategory::Daily.to_string(), "daily"); + assert_eq!(MemoryCategory::Conversation.to_string(), "conversation"); + assert_eq!( + MemoryCategory::Custom("project_notes".into()).to_string(), + "custom:project_notes" + ); +} + +#[test] +fn memory_category_serde_uses_snake_case() { + assert_eq!( + serde_json::to_string(&MemoryCategory::Core).unwrap(), + "\"core\"" + ); + assert_eq!( + serde_json::to_string(&MemoryCategory::Daily).unwrap(), + "\"daily\"" + ); + assert_eq!( + serde_json::to_string(&MemoryCategory::Conversation).unwrap(), + "\"conversation\"" + ); + assert_eq!( + serde_json::to_string(&MemoryCategory::Custom("core".into())).unwrap(), + "\"custom:core\"" + ); + for category in [ + MemoryCategory::Core, + MemoryCategory::Daily, + MemoryCategory::Conversation, + MemoryCategory::Custom("core".into()), + MemoryCategory::Custom("tool_memory".into()), + ] { + assert_eq!( + category.to_string().parse::().unwrap(), + category + ); + let json = serde_json::to_string(&category).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + category + ); + } + assert_eq!( + "project_notes".parse::().unwrap(), + MemoryCategory::Custom("project_notes".into()) + ); +} + +#[test] +fn memory_entry_roundtrip_preserves_optional_fields() { + let entry = MemoryEntry { + id: "id-1".into(), + key: "favorite_language".into(), + content: "Rust".into(), + namespace: Some("global".into()), + category: MemoryCategory::Core, + timestamp: "2026-02-16T00:00:00Z".into(), + session_id: Some("session-abc".into()), + score: Some(0.98), + taint: MemoryTaint::Internal, + }; + let json = serde_json::to_string(&entry).unwrap(); + let parsed: MemoryEntry = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.id, "id-1"); + assert_eq!(parsed.namespace.as_deref(), Some("global")); + assert_eq!(parsed.category, MemoryCategory::Core); + assert_eq!(parsed.session_id.as_deref(), Some("session-abc")); + assert_eq!(parsed.score, Some(0.98)); + assert_eq!(parsed.taint, MemoryTaint::Internal); +} + +#[test] +fn memory_taint_defaults_to_internal_for_legacy_rows() { + let legacy = r#"{ + "id":"x","key":"k","content":"c","namespace":null, + "category":"core","timestamp":"2026-01-01T00:00:00Z", + "session_id":null,"score":null + }"#; + let parsed: MemoryEntry = serde_json::from_str(legacy).unwrap(); + assert_eq!(parsed.taint, MemoryTaint::Internal); +} + +#[test] +fn memory_taint_db_str_roundtrip_and_fails_closed() { + assert_eq!(MemoryTaint::Internal.as_db_str(), "internal"); + assert_eq!(MemoryTaint::ExternalSync.as_db_str(), "external_sync"); + assert_eq!(MemoryTaint::from_db_str("internal"), MemoryTaint::Internal); + assert_eq!( + MemoryTaint::from_db_str("external_sync"), + MemoryTaint::ExternalSync + ); + // Unknown / corrupt values fail closed to the restrictive variant. + assert_eq!(MemoryTaint::from_db_str(""), MemoryTaint::ExternalSync); + assert_eq!( + MemoryTaint::from_db_str("EXTERNAL_SYNC"), + MemoryTaint::ExternalSync + ); + assert_eq!( + MemoryTaint::from_db_str("future"), + MemoryTaint::ExternalSync + ); +} + +#[test] +fn memory_taint_serde_unknown_values_fail_closed() { + assert_eq!( + serde_json::from_str::("\"unexpected\"").unwrap(), + MemoryTaint::ExternalSync + ); + assert_eq!( + serde_json::to_string(&MemoryTaint::ExternalSync).unwrap(), + "\"external_sync\"" + ); +} + +#[test] +fn memory_item_kind_serde_uses_snake_case() { + assert_eq!( + serde_json::to_string(&MemoryItemKind::Document).unwrap(), + "\"document\"" + ); + let decoded: MemoryItemKind = serde_json::from_str("\"episodic\"").unwrap(); + assert_eq!(decoded, MemoryItemKind::Episodic); +} + +#[test] +fn namespace_document_input_defaults_optional_fields() { + let value = json!({ + "namespace": "global", "key": "note-1", "title": "Title", + "content": "Body", "source_type": "manual", "priority": "normal", + "metadata": {}, "category": "core" + }); + let parsed: NamespaceDocumentInput = serde_json::from_value(value).unwrap(); + assert!(parsed.tags.is_empty()); + assert!(parsed.session_id.is_none()); + assert!(parsed.document_id.is_none()); + assert_eq!(parsed.taint, MemoryTaint::Internal); +} + +#[test] +fn namespace_document_input_taint_roundtrips_external_sync() { + let input = NamespaceDocumentInput { + namespace: "skill-gmail".into(), + key: "thread-1".into(), + title: "Subject".into(), + content: "Body".into(), + source_type: "composio-sync".into(), + priority: "medium".into(), + tags: Vec::new(), + metadata: json!({}), + category: "core".into(), + session_id: None, + document_id: None, + taint: MemoryTaint::ExternalSync, + }; + let value = serde_json::to_value(&input).unwrap(); + assert_eq!( + value.get("taint").and_then(|v| v.as_str()), + Some("external_sync") + ); + let parsed: NamespaceDocumentInput = serde_json::from_value(value).unwrap(); + assert_eq!(parsed.taint, MemoryTaint::ExternalSync); +} + +#[test] +fn retrieval_score_breakdown_default_is_zeroed() { + let b = RetrievalScoreBreakdown::default(); + assert_eq!(b.keyword_relevance, 0.0); + assert_eq!(b.vector_similarity, 0.0); + assert_eq!(b.graph_relevance, 0.0); + assert_eq!(b.episodic_relevance, 0.0); + assert_eq!(b.freshness, 0.0); + assert_eq!(b.final_score, 0.0); +} + +#[test] +fn memory_kv_record_roundtrips_with_optional_namespace() { + for record in [ + MemoryKvRecord { + namespace: None, + key: "theme".into(), + value: json!("dark"), + updated_at: 1.5, + }, + MemoryKvRecord { + namespace: Some("project".into()), + key: "state".into(), + value: json!({"open": true}), + updated_at: 2.5, + }, + ] { + let value = serde_json::to_value(&record).unwrap(); + let decoded: MemoryKvRecord = serde_json::from_value(value).unwrap(); + assert_eq!(decoded.namespace, record.namespace); + assert_eq!(decoded.key, record.key); + assert_eq!(decoded.value, record.value); + assert_eq!(decoded.updated_at, record.updated_at); + } +} + +#[test] +fn namespace_memory_hit_defaults_optional_fields_and_taint() { + let hit: NamespaceMemoryHit = serde_json::from_value(json!({ + "id": "hit-1", "kind": "document", "namespace": "global", + "key": "note-1", "title": "Title", "content": "Body", + "category": "core", "source_type": "manual", "updated_at": 3.5, + "score": 0.8, + "score_breakdown": { + "keyword_relevance": 0.5, "vector_similarity": 0.2, + "graph_relevance": 0.0, "episodic_relevance": 0.0, + "freshness": 0.1, "final_score": 0.8 + } + })) + .unwrap(); + assert!(hit.document_id.is_none()); + assert!(hit.chunk_id.is_none()); + assert!(hit.supporting_relations.is_empty()); + assert_eq!(hit.kind, MemoryItemKind::Document); + assert_eq!(hit.taint, MemoryTaint::Internal); +} diff --git a/api/src/version.rs b/api/src/version.rs new file mode 100644 index 0000000..9724674 --- /dev/null +++ b/api/src/version.rs @@ -0,0 +1,91 @@ +//! The memory contract version and the compatibility rule that governs it. +//! +//! Re-exported at the crate root, so the canonical paths are +//! [`crate::CONTRACT_VERSION`] and [`crate::is_compatible`]. +//! +//! ## The rule +//! +//! `CONTRACT_VERSION` is `(major, minor)`: +//! +//! - **Minor bump — an addition that capability negotiation alone makes safe.** +//! A new [`crate::capabilities::Capability`] family is the canonical case: an +//! older driver simply never advertises it, the corresponding RPC methods are +//! unregistered, and the kernel never calls in. A new optional field on an +//! existing wire type, or a new error variant an older kernel can treat as +//! opaque, are the same shape — nothing that already compiled stops +//! compiling, and there is no way for an old driver to be asked for +//! something it never claimed to support. +//! - **Major bump — an existing signature changed, OR a method was added to an +//! already-advertised family.** A method's parameters or return type moved, a +//! mandatory family was added or removed, a wire string changed — or a driver +//! advertising an existing family (say [`crate::capabilities::Capability::Core`]) +//! now has to implement one more method on it. That last case looks additive +//! but is not: capability negotiation has **family granularity only** — there +//! is no way to advertise "`Core`, but without the new method" — so an older +//! driver that still advertises `Core` can be called into a method it does +//! not implement. Bump the major half instead, which forces every driver +//! claiming that family to actually implement the new surface before it can +//! bind again. +//! +//! ## Why only the major half gates the bind +//! +//! An out-of-process driver reports the version it speaks in its handshake +//! (`POST /v1/handshake` → `{ contract_version, driver_id, capabilities[] }`). +//! **A major mismatch refuses the bind**; a minor difference in either +//! direction is accepted, because capability negotiation already covers it: +//! +//! - remote minor > local minor — the driver advertises families this build has +//! never heard of. Unknown family strings are skipped during handshake +//! parsing, so this kernel simply never calls them. +//! - remote minor < local minor — the driver is missing families this build +//! knows about. It does not advertise them, so the corresponding RPC methods +//! are unregistered and the agent tools are absent. That is the ordinary +//! degradation path, not an error. +//! +//! Refusing on a minor difference would therefore reject a driver that is +//! perfectly usable, and would make adding a family a fleet-wide breaking +//! change — which is exactly what the major/minor split exists to avoid. +//! +//! Encoding the rule here rather than in prose means a caller cannot get it +//! subtly wrong: the bind path calls [`is_compatible`], never compares tuples +//! by hand. + +/// Version of the memory contract this crate defines, as `(major, minor)`. +/// +/// See the module docs for the bump rule. Bump the **minor** half only for an +/// addition capability negotiation alone makes safe — a new capability family, +/// a new optional wire field, a new opaque-to-old-kernels error variant. Bump +/// the **major** half — and reset the minor to `0` — for an existing signature +/// change, a mandatory family change, a wire string change, **or a new method +/// added to a family a driver may already advertise** (negotiation is +/// family-granular, not method-granular, so that case cannot be made minor-safe +/// by negotiation alone). +pub const CONTRACT_VERSION: (u16, u16) = (1, 0); + +/// Whether a driver speaking `remote` can be bound against this build. +/// +/// Compatible exactly when the major halves match. See the module docs for why +/// the minor half is informational. +/// +/// # Examples +/// +/// ``` +/// use tinymemory_api::{is_compatible, CONTRACT_VERSION}; +/// +/// // The version this build speaks is always compatible with itself. +/// assert!(is_compatible(CONTRACT_VERSION)); +/// +/// // A minor difference in either direction is fine — capability negotiation +/// // covers the delta. +/// assert!(is_compatible((CONTRACT_VERSION.0, CONTRACT_VERSION.1 + 7))); +/// +/// // A major mismatch refuses the bind. +/// assert!(!is_compatible((CONTRACT_VERSION.0 + 1, 0))); +/// ``` +pub fn is_compatible(remote: (u16, u16)) -> bool { + remote.0 == CONTRACT_VERSION.0 +} + +#[cfg(test)] +#[path = "version_tests.rs"] +mod tests; diff --git a/api/src/version_tests.rs b/api/src/version_tests.rs new file mode 100644 index 0000000..1b3a4c0 --- /dev/null +++ b/api/src/version_tests.rs @@ -0,0 +1,83 @@ +//! Unit tests for the contract version rule in [`super`]. +//! +//! The rule these pin is the one from the kernel design: a **minor** bump means +//! a capability was added and stays compatible; a **major** mismatch refuses +//! the bind. + +use super::*; + +#[test] +fn contract_version_starts_at_one_zero() { + assert_eq!(CONTRACT_VERSION, (1, 0)); +} + +#[test] +fn own_version_is_compatible_with_itself() { + assert!(is_compatible(CONTRACT_VERSION)); +} + +#[test] +fn a_minor_bump_stays_compatible_in_both_directions() { + let (major, minor) = CONTRACT_VERSION; + + // Remote ahead: it advertises families this build does not know. Unknown + // family strings are skipped during handshake parsing. + assert!(is_compatible((major, minor + 1))); + assert!(is_compatible((major, minor + 25))); + assert!(is_compatible((major, u16::MAX))); + + // Remote behind: it lacks families this build knows. Those simply are not + // advertised, so the surface degrades — the ordinary path, not an error. + assert!(is_compatible((major, minor.saturating_sub(1)))); + assert!(is_compatible((major, 0))); +} + +#[test] +fn a_major_mismatch_refuses_the_bind() { + let (major, minor) = CONTRACT_VERSION; + + // Remote ahead by a major: an existing signature changed under us. + assert!(!is_compatible((major + 1, 0))); + assert!(!is_compatible((major + 1, minor))); + assert!(!is_compatible((major + 1, u16::MAX))); + + // Remote behind by a major: same reasoning, other direction. A newer minor + // does not rescue an older major. + assert!(!is_compatible((major - 1, u16::MAX))); + assert!(!is_compatible((0, 0))); +} + +#[test] +fn adding_a_method_to_an_already_advertised_family_requires_a_major_bump() { + // Capability negotiation has family granularity, not method granularity: + // there is no way to advertise "Core, but without the new method". So a + // method added to a family a driver may already advertise (e.g. Core, + // Recall) cannot be made minor-safe by negotiation the way a brand-new + // capability family can — an older driver still advertising that family + // would be called into a method it never implemented. This is why the + // module docs classify that addition as a MAJOR bump, not minor, even + // though it looks additive. This test exists so the rule cannot be + // re-derived from `is_compatible`'s code alone, which only encodes "major + // halves must match" and says nothing about *why* a same-family method + // addition belongs on the major side of that line. + assert!( + !is_compatible((CONTRACT_VERSION.0 + 1, 0)), + "a method added to an existing family must ship as a major bump, \ + which this asserts refuses the bind against an old build" + ); +} + +#[test] +fn compatibility_depends_only_on_the_major_half() { + let (major, _) = CONTRACT_VERSION; + for minor in [0u16, 1, 2, 7, 999, u16::MAX] { + assert!( + is_compatible((major, minor)), + "minor {minor} should not affect compatibility" + ); + assert!( + !is_compatible((major + 1, minor)), + "minor {minor} must not rescue a major mismatch" + ); + } +} diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..e2d8ae6 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,5 @@ +# `doc_markdown` (clippy::pedantic) flags any CamelCase-looking word in prose as +# a code item that forgot its backticks. These are product and technology names +# written as prose on purpose; backticking them would imply they name a Rust +# item. `..` keeps clippy's own default list rather than replacing it. +doc-valid-idents = ["..", "TinyMemory", "TinyCortex", "OpenHuman", "SQLite", "snake_case"] diff --git a/docs/plans/example-retry-policy.md b/docs/plans/example-retry-policy.md deleted file mode 100644 index 0590963..0000000 --- a/docs/plans/example-retry-policy.md +++ /dev/null @@ -1,71 +0,0 @@ -# Example plan: Retry policy - -- **Status:** Example -- **Specification:** - [`../specs/example-retry-policy.md`](../specs/example-retry-policy.md) - -> This is a sample implementation plan, not active work. Replace or remove it -> when generating a real project from this template. - -## Goal - -Add the specified typed retry policy through small red-green-refactor steps, -without adding a runtime, timers, or new dependencies. - -## Task 1: Add the constructor contract - -**Files:** `src/retry/mod.rs`, `src/retry/types.rs`, `src/retry/test.rs` - -1. Create the module skeleton and a failing test for zero attempts: - - ```rust - #[test] - fn rejects_zero_max_attempts() { - assert_eq!( - RetryPolicy::new(0).unwrap_err(), - Error::ZeroMaxAttempts, - ); - } - ``` - -2. Add `Error::ZeroMaxAttempts` in `src/error/mod.rs` and its message assertion - in `src/error/test.rs`. -3. Implement `RetryPolicy::new` using `NonZeroU32`, keeping the field private. -4. Run `cargo test retry` and `cargo clippy --all-targets --all-features -- -D warnings`. - -## Task 2: Add attempt-boundary behavior - -**Files:** `src/retry/mod.rs`, `src/retry/test.rs` - -1. Add failing tests for attempts `0`, `1`, the maximum, and one past it. -2. Implement the smallest boundary check: - - ```rust - #[must_use] - pub fn allows_attempt(self, attempt: u32) -> bool { - attempt != 0 && attempt <= self.max_attempts.get() - } - ``` - -3. Run `cargo test retry`. - -## Task 3: Publish and document the API - -**Files:** `src/lib.rs`, `tests/public_api.rs`, `README.md` - -1. Re-export `RetryPolicy` from `src/lib.rs`. -2. Add an integration test using only `rust_template::{Error, RetryPolicy}`. -3. Add a runnable README example and rustdoc `# Errors` documentation. -4. Run `cargo test --doc` and `cargo test --test public_api`. - -## Task 4: Full verification - -- [ ] `cargo fmt --all -- --check` -- [ ] `cargo clippy --all-targets --all-features -- -D warnings` -- [ ] `cargo build --all-targets --all-features` -- [ ] `cargo test --all-features` -- [ ] `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features` -- [ ] `cargo deny check all` - -When all checks pass, mark the specification Implemented and replace this -example status with the actual completion state. diff --git a/docs/specs/example-retry-policy.md b/docs/specs/example-retry-policy.md deleted file mode 100644 index 39f399d..0000000 --- a/docs/specs/example-retry-policy.md +++ /dev/null @@ -1,63 +0,0 @@ -# Example: Retry policy - -- **Status:** Example -- **Owner:** Maintainers -- **Plan:** [`../plans/example-retry-policy.md`](../plans/example-retry-policy.md) - -> This demonstrates the expected specification format. Replace or remove it -> when generating a real project from this template. - -## Problem - -Callers need a typed way to limit retries without duplicating attempt counting -and validation. The crate currently has no retry behavior. - -## Goals - -- Expose an immutable retry policy with a non-zero maximum attempt count. -- Let callers determine whether another attempt is permitted. -- Reject zero attempts through the crate-wide error type. - -## Non-goals - -- Sleeping, backoff, jitter, or executing operations. -- Deciding which application-specific errors are retryable. -- Persisting retry state. - -## Proposed behavior - -The public surface is deliberately small: - -```rust -use rust_template::{RetryPolicy, Result}; - -fn policy() -> Result { - let policy = RetryPolicy::new(3)?; - assert!(policy.allows_attempt(1)); - assert!(!policy.allows_attempt(4)); - Ok(policy) -} -``` - -`RetryPolicy::new(0)` returns a dedicated `Error::ZeroMaxAttempts` variant. -Attempt numbers are one-based: attempt `1` is the initial call, not the first -retry. - -## Invariants and constraints - -- `max_attempts` is always greater than zero after construction. -- `allows_attempt(n)` is true exactly when `1 <= n <= max_attempts`. -- The type is cheap to copy and does not perform I/O or observe time. -- New public items have rustdoc and are re-exported from `src/lib.rs`. - -## Acceptance criteria - -- Construction succeeds for `1` and `u32::MAX` and fails for `0`. -- Boundary checks cover attempts `0`, `1`, `max_attempts`, and - `max_attempts + 1` when representable. -- Integration tests prove the policy and its error are available to consumers. -- Formatting, Clippy, build, tests, rustdoc, and cargo-deny pass. - -## Open questions - -None for this example. diff --git a/examples/basic.rs b/examples/basic.rs deleted file mode 100644 index 6fa02b8..0000000 --- a/examples/basic.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Minimal end-to-end usage of the crate. -//! -//! Examples are compiled and linted in CI, so they cannot drift from the API. -//! Run it with: -//! -//! ```sh -//! cargo run --example basic -//! ``` - -use rust_template::{Result, greet}; - -fn main() -> Result<()> { - println!("{}", greet("Rust")?); - - // Failure modes are part of the public contract; show them too. - match greet(" ") { - Ok(greeting) => println!("{greeting}"), - Err(error) => println!("expected failure: {error}"), - } - - Ok(()) -} diff --git a/rust_out b/rust_out new file mode 100755 index 0000000..46d1184 Binary files /dev/null and b/rust_out differ diff --git a/src/error/mod.rs b/src/error/mod.rs deleted file mode 100644 index b8ddbe0..0000000 --- a/src/error/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Crate-wide error and result types. -//! -//! Every fallible public function in this crate returns [`Result`], and every -//! failure mode is a distinct [`Error`] variant. Add a variant rather than -//! encoding new context into an existing message: callers match on variants, -//! and message text is not a stable API. -//! -//! Variants carry the data a caller needs to react, keep their `#[error]` -//! message lowercase and free of trailing punctuation, and are documented so -//! the rendered rustdoc explains when each one occurs. - -/// Errors returned by this crate. -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -#[non_exhaustive] -pub enum Error { - /// A required name was empty or contained only whitespace. - #[error("name must not be empty")] - EmptyName, -} - -/// The crate's standard result type. -/// -/// Use this alias in public signatures instead of spelling out -/// `std::result::Result`. -pub type Result = std::result::Result; - -#[cfg(test)] -mod test; diff --git a/src/error/test.rs b/src/error/test.rs deleted file mode 100644 index 4c5d609..0000000 --- a/src/error/test.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Unit tests for the crate-wide error type. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::*; - -#[test] -fn renders_a_human_readable_message() { - assert_eq!(Error::EmptyName.to_string(), "name must not be empty"); -} - -#[test] -fn is_a_standard_error() { - fn assert_error(_: &E) {} - - assert_error(&Error::EmptyName); -} diff --git a/src/greeting/mod.rs b/src/greeting/mod.rs deleted file mode 100644 index 5b4ad65..0000000 --- a/src/greeting/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Greeting behavior used to demonstrate the template's module layout. -//! -//! A module root like this one documents the module, wires its pieces -//! together, and exposes the smallest useful API. Substantial type definitions -//! belong in a sibling `types.rs`, and unit tests belong in `test.rs`, wired in -//! at the bottom of this file. -//! -//! Replace this module with the crate's first real feature area. - -use crate::{Error, Result}; - -/// Returns a friendly greeting for `name`. -/// -/// Surrounding whitespace is trimmed before the greeting is built. -/// -/// # Examples -/// -/// ``` -/// # use rust_template::greet; -/// assert_eq!(greet(" Ferris ")?, "Hello, Ferris!"); -/// # Ok::<(), rust_template::Error>(()) -/// ``` -/// -/// # Errors -/// -/// Returns [`Error::EmptyName`] when `name` is empty or contains only -/// whitespace. -pub fn greet(name: &str) -> Result { - let name = name.trim(); - if name.is_empty() { - return Err(Error::EmptyName); - } - - Ok(format!("Hello, {name}!")) -} - -#[cfg(test)] -mod test; diff --git a/src/greeting/test.rs b/src/greeting/test.rs deleted file mode 100644 index de04ef4..0000000 --- a/src/greeting/test.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Unit tests for the greeting module. -//! -//! Unit tests live next to the code they cover and may reach into private -//! items. Tests of the public contract belong in `tests/` instead. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::*; - -#[test] -fn greets_a_named_person() { - assert_eq!(greet("Ferris").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn trims_the_name() { - assert_eq!(greet(" Ferris ").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn rejects_an_empty_name() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); -} - -#[test] -fn rejects_a_whitespace_only_name() { - assert_eq!(greet(" \t\n ").unwrap_err(), Error::EmptyName); -} diff --git a/src/lib.rs b/src/lib.rs index 95bb03c..646626a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,34 +1,79 @@ -//! A production-ready starting point for a Rust library. +//! TinyMemory — the engine-neutral memory layer. //! -//! This crate is a template. It ships the layout, lint configuration, error -//! handling, testing, and documentation conventions described in `AGENTS.md`, -//! plus one small feature module ([`greet`]) that demonstrates them end to end. +//! A host that embeds TinyMemory performs every memory operation through one +//! contract, and picks which engine answers it by configuration rather than by +//! recompiling. TinyCortex is the default embedded engine; a second engine +//! (`supermemory`, `mem0`, a self-hosted HTTP backend) implements the same +//! traits and binds in its place without the host learning anything new. //! -//! # Layout +//! ## What is here //! -//! - `src/error/` holds the crate-wide [`Error`] enum and the [`Result`] alias -//! returned by every fallible public function. -//! - Each feature area lives in its own module directory with a `mod.rs` -//! module root, an optional `types.rs`, and a `test.rs` holding its unit -//! tests. -//! - Every public item is re-exported from here, so downstream users have a -//! single predictable surface. +//! - **The contract** — [`tinymemory_api`], re-exported wholesale below, so a +//! host takes one dependency and `tinymemory::provider::MemoryProvider` and +//! `tinymemory_api::provider::MemoryProvider` are the same type. It is +//! deliberately dependency-light: depending on the contract never drags in +//! SQLite, git2, reqwest, or an async runtime. +//! - **[`mandatory`]** — the three mandatory capability families, composed +//! once over the [`traits::Memory`] storage trait, so every backend that +//! implements it inherits a correct `store` / `list` / `recall` / export +//! rather than re-deriving the same four subtleties. +//! - **[`registry`]** — driver admission. Which driver ids exist, what class +//! each binds as, and the fail-closed rule for out-of-process drivers. +//! - **Engine adapters** — one crate per engine under `adapters/`, each +//! implementing [`provider::MemoryProvider`] over a concrete engine. //! -//! # Example +//! ## What is deliberately *not* here //! -//! ``` -//! use rust_template::{greet, Error}; +//! Policy. A host that binds a memory driver is responsible for tier +//! enforcement, scope predicates, taint stamping, redaction, egress checks, and +//! audit — and it must apply them in a decorator it owns, on the path every +//! caller takes. Pushing any of that into the engine layer would mean a driver +//! could be swapped for one that does not enforce it, which is the whole reason +//! the policy layer exists. //! -//! assert_eq!(greet("Ferris")?, "Hello, Ferris!"); -//! assert_eq!(greet(" ").unwrap_err(), Error::EmptyName); -//! # Ok::<(), rust_template::Error>(()) -//! ``` +//! Also not here: the host's RPC surface, its agent tools, its credential +//! storage, and its schedulers. Those are what makes a host a host; an engine +//! that learned about them could no longer be replaced by a different engine. +//! +//! ## Binding, end to end +//! +//! ```no_run +//! use tinymemory::null::NullMemoryProvider; +//! use tinymemory::provider::MemoryProvider; +//! use tinymemory::registry::{ConfigLabels, DriverClass, DriverRegistry}; +//! use std::sync::Arc; //! -//! Replace the `greeting` module with the first real feature area, keep the -//! conventions, and update this documentation to describe the new crate. +//! let registry = DriverRegistry::builtin(); +//! let provider: Arc = +//! match registry.admit("tinycortex", None, ConfigLabels::default()) { +//! Ok(admitted) => match admitted.class { +//! // The host constructs the engine adapter it compiled in. +//! DriverClass::Embedded => unimplemented!("bind the engine adapter"), +//! _ => Arc::new(NullMemoryProvider::new()), +//! }, +//! // Refusal is not failure: stay bound, loudly. +//! Err(fallback) => { +//! eprintln!("{fallback}"); +//! Arc::new(NullMemoryProvider::new()) +//! } +//! }; +//! +//! // Ask once, at bind time, and cache: filtering an RPC surface from a set +//! // that can change underneath it is worse than not filtering at all. +//! let capabilities = provider.capabilities(); +//! ``` + +pub mod mandatory; +pub mod registry; -mod error; -mod greeting; +// The contract, re-exported wholesale. Listed module by module rather than as a +// glob so the crate's own surface is visible in one place and rustdoc links +// resolve — and so adding a module to the contract is a deliberate act here too. +pub use tinymemory_api::{ + capabilities, chunks, error, goals, health, null, provider, recall, tool_memory, traits, tree, + types, +}; +pub use tinymemory_api::{is_compatible, CONTRACT_VERSION}; -pub use error::{Error, Result}; -pub use greeting::greet; +/// The contract crate itself, for callers that want to name it explicitly. +pub use tinymemory_api as api; diff --git a/src/mandatory/mod.rs b/src/mandatory/mod.rs new file mode 100644 index 0000000..1e4fa0a --- /dev/null +++ b/src/mandatory/mod.rs @@ -0,0 +1,385 @@ +//! The three mandatory capability families, composed over the storage trait. +//! +//! [`MemoryCore`](tinymemory_api::provider::MemoryCore), [`MemoryRecall`](tinymemory_api::provider::MemoryRecall) and [`MemoryPortability`](tinymemory_api::provider::MemoryPortability) are supertraits of +//! [`MemoryProvider`](tinymemory_api::provider::MemoryProvider): a driver +//! missing any of them cannot be constructed at all. For a backend that already +//! implements [`Memory`], almost all three are mechanical — and the parts that +//! are *not* mechanical are the parts every such backend gets wrong the same +//! way. So they live here once rather than in each driver. +//! +//! ## The four things that are not a straight delegation +//! +//! 1. **`store` maps onto [`Memory::store_with_taint`], never [`Memory::store`].** +//! The contract's `store` always carries a [`MemoryTaint`](tinymemory_api::types::MemoryTaint), because +//! provenance is stamped by the host's policy layer *before* the call. +//! [`Memory::store`] hard-codes [`MemoryTaint::Internal`](tinymemory_api::types::MemoryTaint::Internal), so routing through +//! it would launder externally-sourced content into internal-trust content — +//! the one failure mode a provenance guard exists to prevent. Note +//! [`Memory::store_with_taint`]'s *trait default* also silently drops the +//! taint, so a backend that does not override it is unsafe here; that is a +//! backend bug, not something this layer can paper over. +//! +//! 2. **`list(None, ..)` spans every namespace.** The contract says an +//! all-`None` list returns everything the driver holds. A typical [`Memory`] +//! implementation normalises a `None` namespace to +//! [`GLOBAL_NAMESPACE`], so a naive delegation returns one namespace and +//! calls it "everything". [`list_everything`] composes `namespace_summaries` +//! with a per-namespace `list` instead. +//! +//! 3. **A scoped recall is refused, not ignored.** See [`recall`]. +//! +//! 4. **Import must not re-stamp provenance.** See [`import_records`]. +//! +//! ## Why free functions rather than a blanket impl +//! +//! A blanket `impl MemoryCore for T` would collide with any +//! driver that wants to override one method, and would force every driver to +//! resolve its handle through one shape. These are plain functions taking +//! `&dyn Memory`, so a driver delegates the parts it wants and keeps its own +//! logging, laziness, and error context. [`MemoryTraitProvider`] is the +//! batteries-included alternative for a backend that wants all three whole. + +use tinymemory_api::error::MemoryError; +use tinymemory_api::provider::types::{ExportPage, ExportRecord, ImportOutcome, SourceScope}; +use tinymemory_api::recall::OwnedRecallOpts; +use tinymemory_api::traits::Memory; +use tinymemory_api::types::{MemoryCategory, MemoryEntry, RecallOpts, GLOBAL_NAMESPACE}; + +mod provider; + +pub use provider::MemoryTraitProvider; + +#[cfg(test)] +#[path = "test.rs"] +mod test; + +/// The [`ExportRecord::kind`] emitted and accepted by the mandatory-only +/// export. +/// +/// A driver whose export widens to documents or chunks emits additional kinds +/// alongside this one; it must keep accepting this one, or an export taken +/// before the widening stops importing. +pub const ENTRY_KIND: &str = "entry"; + +/// Refusal message for a scoped recall on a driver with no scope predicate. +/// +/// A constant so a caller's test asserts the same string the caller sees. +pub const SCOPE_UNAPPLIED: &str = + "source scope is not applied by this driver's recall path yet: the scope predicate belongs \ + inside the query and lands with the tree capability family"; + +/// Maps a storage-layer `anyhow` failure onto the contract's error type. +/// +/// [`Memory`] is deliberately `anyhow`-typed — it is an internal storage +/// abstraction over heterogeneous backends — so everything it returns is opaque +/// and lands in [`MemoryError::Other`]. The typed variants (`Invalid`, +/// `NotFound`, `Unsupported`) are constructed by the driver, where the reason is +/// actually known. +#[must_use] +pub fn engine_error(error: anyhow::Error) -> MemoryError { + MemoryError::Other(error) +} + +/// `MemoryCore::list` for the all-namespaces case. +/// +/// Call this only when the caller passed `namespace: None`; a `Some` namespace +/// delegates straight to [`Memory::list`]. +/// +/// # Errors +/// +/// Backend failures from `namespace_summaries` or any per-namespace `list`. +pub async fn list_everything( + memory: &dyn Memory, + category: Option<&MemoryCategory>, + session_id: Option<&str>, +) -> Result, MemoryError> { + let summaries = memory.namespace_summaries().await.map_err(engine_error)?; + log::debug!( + "[tinymemory:mandatory] list spanning {} namespace(s)", + summaries.len() + ); + let mut entries = Vec::new(); + for summary in summaries { + let mut page = memory + .list(Some(&summary.namespace), category, session_id) + .await + .map_err(engine_error)?; + entries.append(&mut page); + } + Ok(entries) +} + +/// `MemoryRecall::recall` over a [`Memory`] backend with no scope predicate. +/// +/// The contract's `scope` is a **query predicate the driver must apply +/// internally**. Applying it after the fact would let `limit` be consumed by +/// rows the caller may not see, and an empty scope must deny all +/// source-attributed content rather than wave it through. +/// +/// A [`Memory`] backend has no such predicate: `recall` ranks over a namespace +/// and consults nothing resembling a [`SourceScope`]. That leaves three +/// possible treatments of `Some(scope)`, and two are wrong — silently ignoring +/// it is an invisible leak, and post-filtering is the failure mode above. So +/// this refuses. +/// +/// [`MemoryError::Invalid`] is the right variant rather than `Unsupported`: +/// `Unsupported` names a whole capability *family*, and recall is advertised. +/// +/// # Errors +/// +/// [`MemoryError::Invalid`] when `scope` is `Some`; otherwise backend failures. +pub async fn recall( + memory: &dyn Memory, + query: &str, + limit: usize, + opts: &OwnedRecallOpts, + scope: Option<&SourceScope>, +) -> Result, MemoryError> { + if scope.is_some() { + log::warn!("[tinymemory:mandatory] recall refused: {SCOPE_UNAPPLIED}"); + return Err(MemoryError::Invalid(SCOPE_UNAPPLIED.to_string())); + } + + // Zero-copy for the string filters; `RecallOpts::from` destructures + // exhaustively inside the contract crate, so a new filter cannot be + // dropped silently here. + let borrowed = RecallOpts::from(opts); + memory + .recall(query, limit, borrowed) + .await + .map_err(engine_error) +} + +/// Parses an export cursor of the form `"{namespace_index}:{offset}"`. +/// +/// `None` means "start", i.e. `(0, 0)`. +fn parse_cursor(cursor: Option<&str>) -> Result<(usize, usize), MemoryError> { + let Some(raw) = cursor else { + return Ok((0, 0)); + }; + let invalid = + || MemoryError::Invalid(format!("export cursor not issued by this driver: {raw}")); + let (index, offset) = raw.split_once(':').ok_or_else(invalid)?; + Ok(( + index.parse().map_err(|_| invalid())?, + offset.parse().map_err(|_| invalid())?, + )) +} + +/// Renders one entry as an export record. +/// +/// A record round-trips the five fields [`MemoryCore`](tinymemory_api::provider::MemoryCore) +/// owns — `key`, `content`, `category`, `session_id`, `taint` — plus its +/// namespace and timestamp. Document-tier attributes (`title`, `tags`, +/// `metadata`, `source_type`, `priority`) belong to the `Documents` family and +/// are out of scope; a re-import synthesises them exactly as a normal store +/// does. That is a stated limitation of a mandatory-only export, not an +/// oversight — it widens when a driver's Documents family joins the export. +#[must_use] +pub fn to_record(entry: MemoryEntry) -> ExportRecord { + ExportRecord { + kind: ENTRY_KIND.to_string(), + id: entry.id, + namespace: entry.namespace, + taint: entry.taint, + payload: serde_json::json!({ + "key": entry.key, + "content": entry.content, + "category": entry.category.to_string(), + "session_id": entry.session_id, + "timestamp": entry.timestamp, + }), + } +} + +/// What [`import_records`] needs out of one record's payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportedEntry { + /// Owning namespace, defaulted to [`GLOBAL_NAMESPACE`] when the record has + /// none. + pub namespace: String, + /// The entry key. + pub key: String, + /// The entry body. + pub content: String, + /// The entry category. + pub category: MemoryCategory, + /// The originating session, when the record carried one. + pub session_id: Option, +} + +/// Reads a record into the fields a [`Memory`] backend needs to store it. +/// +/// # Errors +/// +/// An operator-facing reason with **no record content in it** — these strings +/// land in [`ImportOutcome::errors`], which is logged. +pub fn read_record(record: &ExportRecord) -> Result { + if record.kind != ENTRY_KIND { + return Err(format!( + "record {}: unsupported kind '{}' (this driver exports '{ENTRY_KIND}')", + record.id, record.kind + )); + } + let string_field = |name: &str| -> Result<&str, String> { + record + .payload + .get(name) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("record {}: payload is missing a string '{name}'", record.id)) + }; + let key = string_field("key")?; + let content = string_field("content")?; + let category: MemoryCategory = string_field("category")? + .parse() + .map_err(|_| format!("record {}: category is not a known category", record.id))?; + + Ok(ImportedEntry { + namespace: record + .namespace + .clone() + .unwrap_or_else(|| GLOBAL_NAMESPACE.to_string()), + key: key.to_string(), + content: content.to_string(), + category, + session_id: record + .payload + .get("session_id") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + }) +} + +/// `MemoryPortability::export_page` over a [`Memory`] backend. +/// +/// Composed from `namespace_summaries()` and `list()`. Deliberately *not* from +/// a document-listing query: those typically select metadata and no `content`, +/// so an export built on one round-trips titles and loses every byte of memory. +/// +/// The cursor is `"{namespace_index}:{offset}"`, indexing into +/// `namespace_summaries()`, whose ordering is stable. An empty page is **not** +/// a terminator — only a `None` next-cursor is — so an empty namespace advances +/// the index and keeps paging. +/// +/// # Errors +/// +/// [`MemoryError::Invalid`] for a zero limit or a cursor this driver did not +/// issue; otherwise backend failures. +pub async fn export_page( + memory: &dyn Memory, + cursor: Option<&str>, + limit: usize, +) -> Result { + if limit == 0 { + return Err(MemoryError::Invalid( + "export page limit must be greater than zero".to_string(), + )); + } + let (index, offset) = parse_cursor(cursor)?; + let summaries = memory.namespace_summaries().await.map_err(engine_error)?; + + if index >= summaries.len() { + // A start-of-export against an empty store lands here legitimately; any + // other out-of-range index came from a cursor we did not issue. + if cursor.is_some() && !summaries.is_empty() { + return Err(MemoryError::Invalid(format!( + "export cursor names namespace #{index}, but this driver holds {}", + summaries.len() + ))); + } + return Ok(ExportPage { + records: Vec::new(), + next_cursor: None, + }); + } + + let namespace = &summaries[index].namespace; + let entries = memory + .list(Some(namespace), None, None) + .await + .map_err(engine_error)?; + if offset > entries.len() { + return Err(MemoryError::Invalid(format!( + "export cursor offset {offset} is past the end of namespace #{index}" + ))); + } + + let end = offset.saturating_add(limit).min(entries.len()); + let records: Vec = entries[offset..end] + .iter() + .cloned() + .map(to_record) + .collect(); + + let next_cursor = if end < entries.len() { + Some(format!("{index}:{end}")) + } else if index + 1 < summaries.len() { + Some(format!("{}:0", index + 1)) + } else { + None + }; + + log::debug!( + "[tinymemory:mandatory] export_page index={index} offset={offset} emitted={} more={}", + records.len(), + next_cursor.is_some() + ); + Ok(ExportPage { + records, + next_cursor, + }) +} + +/// `MemoryPortability::import_records` over a [`Memory`] backend. +/// +/// Each record is stored with its **own** [`MemoryTaint`](tinymemory_api::types::MemoryTaint) via +/// [`Memory::store_with_taint`]: an importing driver must persist the +/// provenance it is given and must not re-stamp it. [`Memory::store`] would +/// stamp [`MemoryTaint::Internal`](tinymemory_api::types::MemoryTaint::Internal), quietly upgrading the trust of every +/// externally-sourced record in a restore. +/// +/// Per-record rejection is reported in [`ImportOutcome`], never fatal: a +/// million-record restore must not abort on one malformed row. +/// +/// # Errors +/// +/// Reserved for failures that make the whole batch meaningless — a backend +/// write that fails aborts the batch, because continuing would report a partial +/// restore as a successful one. +pub async fn import_records( + memory: &dyn Memory, + records: Vec, +) -> Result { + let mut outcome = ImportOutcome::default(); + + for record in records { + let entry = match read_record(&record) { + Ok(entry) => entry, + Err(reason) => { + outcome.failed = outcome.failed.saturating_add(1); + outcome.errors.push(reason); + continue; + } + }; + + memory + .store_with_taint( + &entry.namespace, + &entry.key, + &entry.content, + entry.category, + entry.session_id.as_deref(), + record.taint, + ) + .await + .map_err(engine_error)?; + outcome.imported = outcome.imported.saturating_add(1); + } + + log::debug!( + "[tinymemory:mandatory] import_records imported={} failed={}", + outcome.imported, + outcome.failed + ); + Ok(outcome) +} diff --git a/src/mandatory/provider.rs b/src/mandatory/provider.rs new file mode 100644 index 0000000..4964bb3 --- /dev/null +++ b/src/mandatory/provider.rs @@ -0,0 +1,203 @@ +//! [`MemoryTraitProvider`] — a complete, mandatory-only +//! [`MemoryProvider`](tinymemory_api::provider::MemoryProvider) over any +//! [`Memory`] backend. +//! +//! ## What this is for +//! +//! Two things, and it is worth being clear which is which. +//! +//! **A real driver for a simple backend.** A store that implements [`Memory`] +//! becomes a bindable memory driver by wrapping it here — no capability +//! plumbing, no export format to invent. It advertises exactly the three +//! mandatory families, so a host binding it gets a memory subsystem whose +//! optional surface is *absent* rather than present-and-failing. +//! +//! **A conformance baseline.** Because it composes the same functions a richer +//! driver delegates to, testing a backend through this type tests the shared +//! layer directly, without a host in the picture. +//! +//! ## What it deliberately does not do +//! +//! No optional families. Every `as_*` accessor keeps the contract's `None` +//! default, and [`capabilities`](MemoryTraitProvider::capabilities) reports the +//! mandatory three — so the two halves agree and +//! [`audit_provider`](tinymemory_api::provider::audit_provider) passes. A driver +//! that wants documents, trees, or a diff ledger implements those families over +//! its own engine and delegates only the mandatory three here. +//! +//! No policy. Tier checks, scope predicates, taint stamping, redaction and +//! audit belong in a decorator the *host* owns — see the crate docs. + +use std::sync::Arc; + +use async_trait::async_trait; +use tinymemory_api::capabilities::{Capabilities, Capability}; +use tinymemory_api::error::MemoryError; +use tinymemory_api::health::MemoryHealth; +use tinymemory_api::provider::types::{ExportPage, ExportRecord, ImportOutcome, SourceScope}; +use tinymemory_api::provider::{MemoryCore, MemoryPortability, MemoryProvider, MemoryRecall}; +use tinymemory_api::recall::OwnedRecallOpts; +use tinymemory_api::traits::Memory; +use tinymemory_api::types::{MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary}; + +use super::{engine_error, export_page, import_records, list_everything, recall}; + +/// A mandatory-only memory driver over an [`Memory`] backend. +#[derive(Clone)] +pub struct MemoryTraitProvider { + memory: Arc, + driver_id: String, +} + +impl std::fmt::Debug for MemoryTraitProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // `dyn Memory` is not `Debug`, and a backend handle is not something to + // render anyway — it may hold a connection string. + f.debug_struct("MemoryTraitProvider") + .field("driver_id", &self.driver_id) + .finish_non_exhaustive() + } +} + +impl MemoryTraitProvider { + /// Wrap `memory` as a driver reporting `driver_id`. + /// + /// `driver_id` must be stable across restarts and must not embed a URL, a + /// token, or anything else deployment-specific: it appears in status + /// output, log lines, tracing spans, and audit events. + #[must_use] + pub fn new(memory: Arc, driver_id: impl Into) -> Self { + Self { + memory, + driver_id: driver_id.into(), + } + } + + /// The wrapped backend. + #[must_use] + pub fn memory(&self) -> &Arc { + &self.memory + } + + /// The families this type implements: the mandatory three, and nothing + /// else. + #[must_use] + pub fn advertised_capabilities() -> Capabilities { + Capabilities::from_iter([ + Capability::Core, + Capability::Recall, + Capability::Portability, + ]) + } +} + +#[async_trait] +impl MemoryCore for MemoryTraitProvider { + async fn store( + &self, + namespace: &str, + key: &str, + content: &str, + category: MemoryCategory, + session_id: Option<&str>, + taint: MemoryTaint, + ) -> Result<(), MemoryError> { + // `store_with_taint`, never `store` — see the module docs on `super`. + self.memory + .store_with_taint(namespace, key, content, category, session_id, taint) + .await + .map_err(engine_error) + } + + async fn get(&self, namespace: &str, key: &str) -> Result, MemoryError> { + self.memory.get(namespace, key).await.map_err(engine_error) + } + + async fn forget(&self, namespace: &str, key: &str) -> Result { + self.memory + .forget(namespace, key) + .await + .map_err(engine_error) + } + + async fn list( + &self, + namespace: Option<&str>, + category: Option<&MemoryCategory>, + session_id: Option<&str>, + ) -> Result, MemoryError> { + match namespace { + Some(namespace) => self + .memory + .list(Some(namespace), category, session_id) + .await + .map_err(engine_error), + None => list_everything(self.memory.as_ref(), category, session_id).await, + } + } + + async fn namespaces(&self) -> Result, MemoryError> { + // The contract's `namespaces` is the backend's `namespace_summaries`; + // the return type is identical, only the name differs. + self.memory + .namespace_summaries() + .await + .map_err(engine_error) + } +} + +#[async_trait] +impl MemoryRecall for MemoryTraitProvider { + async fn recall( + &self, + query: &str, + limit: usize, + opts: &OwnedRecallOpts, + scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + recall(self.memory.as_ref(), query, limit, opts, scope).await + } +} + +#[async_trait] +impl MemoryPortability for MemoryTraitProvider { + async fn export_page( + &self, + cursor: Option<&str>, + limit: usize, + ) -> Result { + export_page(self.memory.as_ref(), cursor, limit).await + } + + async fn import_records( + &self, + records: Vec, + ) -> Result { + import_records(self.memory.as_ref(), records).await + } +} + +#[async_trait] +impl MemoryProvider for MemoryTraitProvider { + fn driver_id(&self) -> &str { + &self.driver_id + } + + fn capabilities(&self) -> Capabilities { + Self::advertised_capabilities() + } + + async fn health(&self) -> MemoryHealth { + if self.memory.health_check().await { + MemoryHealth::Ready + } else { + // No path and no connection detail: this string is logged and + // rendered in operator-facing status. + MemoryHealth::down("memory backend reported unhealthy") + } + } + + // `shutdown` keeps the contract's no-op default. The backend handle is an + // `Arc` this type does not own exclusively; a driver must not tear down a + // handle its host may still hold. +} diff --git a/src/mandatory/test.rs b/src/mandatory/test.rs new file mode 100644 index 0000000..704f535 --- /dev/null +++ b/src/mandatory/test.rs @@ -0,0 +1,567 @@ +//! Tests for the shared mandatory-family logic. +//! +//! These run against [`VecMemory`], a deliberately dumb in-process [`Memory`] +//! backend defined here rather than borrowed from an engine crate: the point of +//! this module is that the logic is engine-neutral, and a test that needed a +//! real engine would not demonstrate that. + +// A failing assertion in a test *is* a panic; the crate-wide `expect_used` / +// `panic` lints exist to keep the library from panicking, not the tests. +#![allow(clippy::expect_used, clippy::panic)] + +use std::collections::BTreeMap; +use std::sync::Mutex; + +use tinymemory_api::provider::audit_provider; + +use super::*; + +/// A minimal `Memory` over a `BTreeMap`, keyed `(namespace, key)`. +/// +/// `store_with_taint` is **overridden**, which is the whole point: the trait +/// default silently drops the taint, so a backend relying on it could not +/// preserve provenance across an import and the taint tests below would pass +/// for the wrong reason. +#[derive(Default)] +struct VecMemory { + entries: Mutex>, + healthy: bool, +} + +impl VecMemory { + fn healthy() -> Arc { + Arc::new(Self { + entries: Mutex::new(BTreeMap::new()), + healthy: true, + }) + } + + fn unhealthy() -> Arc { + Arc::new(Self { + entries: Mutex::new(BTreeMap::new()), + healthy: false, + }) + } +} + +use std::sync::Arc; + +use async_trait::async_trait; +use tinymemory_api::types::NamespaceSummary; + +#[async_trait] +impl Memory for VecMemory { + async fn store( + &self, + namespace: &str, + key: &str, + content: &str, + category: MemoryCategory, + session_id: Option<&str>, + ) -> anyhow::Result<()> { + self.store_with_taint( + namespace, + key, + content, + category, + session_id, + tinymemory_api::types::MemoryTaint::Internal, + ) + .await + } + + async fn store_with_taint( + &self, + namespace: &str, + key: &str, + content: &str, + category: MemoryCategory, + session_id: Option<&str>, + taint: tinymemory_api::types::MemoryTaint, + ) -> anyhow::Result<()> { + let entry = MemoryEntry { + id: format!("{namespace}/{key}"), + key: key.to_string(), + content: content.to_string(), + namespace: Some(namespace.to_string()), + category, + timestamp: "2026-08-10T00:00:00Z".to_string(), + session_id: session_id.map(str::to_string), + score: None, + taint, + }; + self.entries + .lock() + .expect("lock") + .insert((namespace.to_string(), key.to_string()), entry); + Ok(()) + } + + async fn recall( + &self, + query: &str, + limit: usize, + opts: RecallOpts<'_>, + ) -> anyhow::Result> { + let entries = self.entries.lock().expect("lock"); + Ok(entries + .values() + .filter(|e| { + opts.namespace + .is_none_or(|ns| e.namespace.as_deref() == Some(ns)) + }) + .filter(|e| e.content.contains(query)) + .take(limit) + .cloned() + .collect()) + } + + async fn get(&self, namespace: &str, key: &str) -> anyhow::Result> { + Ok(self + .entries + .lock() + .expect("lock") + .get(&(namespace.to_string(), key.to_string())) + .cloned()) + } + + /// Deliberately reproduces the trap the shared layer exists to avoid: a + /// `None` namespace is normalised to the global namespace rather than + /// meaning "everything". + async fn list( + &self, + namespace: Option<&str>, + category: Option<&MemoryCategory>, + session_id: Option<&str>, + ) -> anyhow::Result> { + let wanted = namespace.unwrap_or(GLOBAL_NAMESPACE); + let entries = self.entries.lock().expect("lock"); + Ok(entries + .values() + .filter(|e| e.namespace.as_deref() == Some(wanted)) + .filter(|e| category.is_none_or(|c| &e.category == c)) + .filter(|e| session_id.is_none_or(|s| e.session_id.as_deref() == Some(s))) + .cloned() + .collect()) + } + + async fn forget(&self, namespace: &str, key: &str) -> anyhow::Result { + Ok(self + .entries + .lock() + .expect("lock") + .remove(&(namespace.to_string(), key.to_string())) + .is_some()) + } + + async fn namespace_summaries(&self) -> anyhow::Result> { + let entries = self.entries.lock().expect("lock"); + let mut counts: BTreeMap = BTreeMap::new(); + for entry in entries.values() { + *counts + .entry(entry.namespace.clone().unwrap_or_default()) + .or_default() += 1; + } + Ok(counts + .into_iter() + .map(|(namespace, count)| NamespaceSummary { + namespace, + count, + last_updated: None, + }) + .collect()) + } + + async fn count(&self) -> anyhow::Result { + Ok(self.entries.lock().expect("lock").len()) + } + + async fn health_check(&self) -> bool { + self.healthy + } + + fn name(&self) -> &'static str { + "vec" + } +} + +async fn seeded() -> Arc { + let memory = VecMemory::healthy(); + memory + .store(GLOBAL_NAMESPACE, "a", "alpha", MemoryCategory::Core, None) + .await + .expect("store"); + memory + .store("projects", "b", "beta", MemoryCategory::Core, None) + .await + .expect("store"); + memory + .store("projects", "c", "gamma", MemoryCategory::Core, None) + .await + .expect("store"); + memory +} + +fn provider(memory: Arc) -> MemoryTraitProvider { + MemoryTraitProvider::new(memory, "vec") +} + +/// The bug this layer exists to prevent: a backend that normalises a `None` +/// namespace to the global one would report one namespace as "everything". +#[tokio::test] +async fn list_everything_spans_every_namespace() { + let memory = seeded().await; + + let naive = memory.list(None, None, None).await.expect("naive list"); + assert_eq!(naive.len(), 1, "the backend alone narrows to one namespace"); + + let all = list_everything(memory.as_ref(), None, None) + .await + .expect("list everything"); + assert_eq!(all.len(), 3); +} + +#[tokio::test] +async fn list_with_a_namespace_still_narrows() { + let all = provider(seeded().await) + .list(Some("projects"), None, None) + .await + .expect("list"); + assert_eq!(all.len(), 2); +} + +/// `store` must route through `store_with_taint`, or externally-sourced content +/// is laundered into internal-trust content. +#[tokio::test] +async fn store_preserves_the_taint_it_is_given() { + let memory = VecMemory::healthy(); + provider(Arc::clone(&memory)) + .store( + "ns", + "k", + "body", + MemoryCategory::Core, + None, + MemoryTaint::ExternalSync, + ) + .await + .expect("store"); + + let stored = memory.get("ns", "k").await.expect("get").expect("present"); + assert_eq!(stored.taint, MemoryTaint::ExternalSync); +} + +#[tokio::test] +async fn a_scoped_recall_is_refused_rather_than_answered_in_full() { + let scope = SourceScope::default(); + let error = recall( + seeded().await.as_ref(), + "a", + 10, + &OwnedRecallOpts::default(), + Some(&scope), + ) + .await + .expect_err("a scoped recall is refused"); + + match error { + MemoryError::Invalid(reason) => assert_eq!(reason, SCOPE_UNAPPLIED), + other => panic!("expected Invalid, got {other:?}"), + } +} + +#[tokio::test] +async fn an_unscoped_recall_delegates() { + let hits = provider(seeded().await) + .recall("alpha", 10, &OwnedRecallOpts::default(), None) + .await + .expect("recall"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].key, "a"); +} + +#[tokio::test] +async fn export_pages_across_namespaces_and_terminates_on_a_none_cursor() { + let driver = provider(seeded().await); + + let mut seen = Vec::new(); + let mut cursor = None; + let mut pages = 0; + loop { + let page = driver + .export_page(cursor.as_deref(), 2) + .await + .expect("export page"); + pages += 1; + assert!(pages < 10, "export did not terminate"); + seen.extend(page.records.iter().map(|r| r.id.clone())); + match page.next_cursor { + Some(next) => cursor = Some(next), + None => break, + } + } + + seen.sort(); + assert_eq!(seen, vec!["global/a", "projects/b", "projects/c"]); +} + +#[tokio::test] +async fn an_empty_store_exports_one_empty_terminal_page() { + let page = provider(VecMemory::healthy()) + .export_page(None, 10) + .await + .expect("export page"); + assert!(page.records.is_empty()); + assert!(page.next_cursor.is_none()); +} + +#[tokio::test] +async fn a_zero_limit_is_refused() { + let error = provider(seeded().await) + .export_page(None, 0) + .await + .expect_err("a zero page size cannot make progress"); + assert!(matches!(error, MemoryError::Invalid(_))); +} + +#[tokio::test] +async fn a_cursor_this_driver_did_not_issue_is_refused() { + let driver = provider(seeded().await); + for bogus in ["nonsense", "1", "x:0", "0:y"] { + let error = driver + .export_page(Some(bogus), 10) + .await + .expect_err("bogus cursor"); + assert!( + matches!(error, MemoryError::Invalid(_)), + "cursor {bogus:?} should be Invalid" + ); + } + + let error = driver + .export_page(Some("99:0"), 10) + .await + .expect_err("out-of-range namespace index"); + assert!(matches!(error, MemoryError::Invalid(_))); +} + +/// The round trip is the point of the family: a driver you cannot export from +/// is a driver you cannot unbind. +#[tokio::test] +async fn export_round_trips_through_import_with_provenance_intact() { + let source = VecMemory::healthy(); + source + .store_with_taint( + "ns", + "external", + "from a sync", + MemoryCategory::Core, + Some("s1"), + MemoryTaint::ExternalSync, + ) + .await + .expect("store"); + source + .store_with_taint( + "ns", + "internal", + "typed by the user", + MemoryCategory::Daily, + None, + MemoryTaint::Internal, + ) + .await + .expect("store"); + + let page = provider(Arc::clone(&source)) + .export_page(None, 10) + .await + .expect("export"); + assert_eq!(page.records.len(), 2); + + let target = VecMemory::healthy(); + let outcome = provider(Arc::clone(&target)) + .import_records(page.records) + .await + .expect("import"); + assert_eq!(outcome.imported, 2); + assert_eq!(outcome.failed, 0); + + let external = target + .get("ns", "external") + .await + .expect("get") + .expect("present"); + assert_eq!( + external.taint, + MemoryTaint::ExternalSync, + "an importing driver must not re-stamp provenance" + ); + assert_eq!(external.content, "from a sync"); + assert_eq!(external.session_id.as_deref(), Some("s1")); + assert_eq!(external.category, MemoryCategory::Core); + + let internal = target + .get("ns", "internal") + .await + .expect("get") + .expect("present"); + assert_eq!(internal.taint, MemoryTaint::Internal); + assert_eq!(internal.category, MemoryCategory::Daily); +} + +/// A malformed record is reported, not fatal — a large restore must not abort +/// on one bad row. +#[tokio::test] +async fn a_malformed_record_is_reported_without_aborting_the_batch() { + let target = VecMemory::healthy(); + let good = to_record(MemoryEntry { + id: "ns/ok".to_string(), + key: "ok".to_string(), + content: "body".to_string(), + namespace: Some("ns".to_string()), + category: MemoryCategory::Core, + timestamp: "2026-08-10T00:00:00Z".to_string(), + session_id: None, + score: None, + taint: MemoryTaint::Internal, + }); + let wrong_kind = ExportRecord { + kind: "document".to_string(), + id: "ns/doc".to_string(), + namespace: Some("ns".to_string()), + taint: MemoryTaint::Internal, + payload: serde_json::json!({}), + }; + let missing_content = ExportRecord { + kind: ENTRY_KIND.to_string(), + id: "ns/partial".to_string(), + namespace: Some("ns".to_string()), + taint: MemoryTaint::Internal, + payload: serde_json::json!({ "key": "partial", "category": "core" }), + }; + + let outcome = provider(Arc::clone(&target)) + .import_records(vec![wrong_kind, good, missing_content]) + .await + .expect("import"); + + assert_eq!(outcome.imported, 1); + assert_eq!(outcome.failed, 2); + assert_eq!( + outcome.errors.len(), + 2, + "every rejection must be diagnosable" + ); + assert!(target.get("ns", "ok").await.expect("get").is_some()); +} + +/// Rejection reasons are logged, so they must name the record and the problem +/// and carry none of its content. +#[tokio::test] +async fn a_rejection_reason_carries_no_record_content() { + let secret = "hunter2-do-not-log-me"; + let record = ExportRecord { + kind: ENTRY_KIND.to_string(), + id: "ns/partial".to_string(), + namespace: Some("ns".to_string()), + taint: MemoryTaint::Internal, + payload: serde_json::json!({ "key": "partial", "content": secret }), + }; + let reason = read_record(&record).expect_err("missing category"); + assert!( + reason.contains("ns/partial"), + "reason should name the record" + ); + assert!( + reason.contains("category"), + "reason should name the problem" + ); + assert!(!reason.contains(secret), "reason must not carry content"); +} + +/// A record with no namespace lands in the global namespace rather than being +/// dropped. +#[tokio::test] +async fn a_namespaceless_record_imports_globally() { + let target = VecMemory::healthy(); + let record = ExportRecord { + kind: ENTRY_KIND.to_string(), + id: "orphan".to_string(), + namespace: None, + taint: MemoryTaint::Internal, + payload: serde_json::json!({ "key": "k", "content": "v", "category": "core" }), + }; + let outcome = provider(Arc::clone(&target)) + .import_records(vec![record]) + .await + .expect("import"); + assert_eq!(outcome.imported, 1); + assert!(target + .get(GLOBAL_NAMESPACE, "k") + .await + .expect("get") + .is_some()); +} + +/// Advertised capabilities and reachable accessors must agree, or a host +/// filters its RPC surface from a claim the driver cannot honour. +#[tokio::test] +async fn the_advertised_set_matches_what_is_actually_reachable() { + let driver = provider(VecMemory::healthy()); + audit_provider(&driver).expect("advertised capabilities match the accessors"); + + let capabilities = driver.capabilities(); + for mandatory in [ + Capability::Core, + Capability::Recall, + Capability::Portability, + ] { + assert!(capabilities.contains(mandatory)); + assert!(driver.provides(mandatory)); + } + for optional in [ + Capability::Ingest, + Capability::Documents, + Capability::Tree, + Capability::Entities, + Capability::Graph, + Capability::Diff, + Capability::Goals, + Capability::ToolMemory, + Capability::Sources, + Capability::Maintenance, + ] { + assert!( + !capabilities.contains(optional), + "{optional:?} must be absent, not present-and-failing" + ); + assert!(!driver.provides(optional)); + } +} + +#[tokio::test] +async fn health_follows_the_backend() { + assert_eq!( + provider(VecMemory::healthy()).health().await, + MemoryHealth::Ready + ); + assert!(matches!( + provider(VecMemory::unhealthy()).health().await, + MemoryHealth::Down { .. } + )); +} + +/// A driver id appears in logs and audit events, so it must not be rendered +/// from a backend handle that could hold a connection string. +#[test] +fn debug_renders_the_driver_id_and_not_the_backend() { + let rendered = format!("{:?}", provider(VecMemory::healthy())); + assert!(rendered.contains("vec")); + assert!(!rendered.contains("VecMemory")); +} + +use tinymemory_api::capabilities::Capability; +use tinymemory_api::health::MemoryHealth; +use tinymemory_api::provider::{MemoryCore, MemoryPortability, MemoryProvider, MemoryRecall}; +use tinymemory_api::types::MemoryTaint; diff --git a/src/registry/class.rs b/src/registry/class.rs new file mode 100644 index 0000000..218e2f6 --- /dev/null +++ b/src/registry/class.rs @@ -0,0 +1,99 @@ +//! [`DriverClass`] — how a bound driver is reached. +//! +//! Class is a fact about how the *host* bound a driver, recorded in host +//! configuration. It is deliberately absent from +//! [`MemoryProvider`](tinymemory_api::provider::MemoryProvider): a driver that +//! self-reported its class could let a misconfigured external backend claim to +//! be embedded and skip the trust checks class gates. +//! +//! A host that runs several pluggable subsystems will have its own generic +//! class enum shared across them. This one is shaped identically (three +//! variants, the same snake_case spellings) so the boundary conversion is a +//! total three-arm `match` that cannot drift. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +/// Error returned when a driver class is not recognized. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DriverClassParseError { + /// The raw class value is unsupported. + Unknown { + /// The unrecognized input, retained for diagnostics that stay local. + raw: String, + }, +} + +impl fmt::Display for DriverClassParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("unknown driver class") + } +} + +impl std::error::Error for DriverClassParseError {} + +/// How a bound driver is reached. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DriverClass { + /// An in-tree / vendored Rust crate. The default: no network, no extra + /// process. + Embedded, + /// An out-of-process backend reached through a transport adapter over a + /// documented wire contract. + External, + /// A stub advertising zero optional capabilities — what a compiled-out or + /// unconfigured memory subsystem binds to. + Null, +} + +impl DriverClass { + /// Every class, in declaration order. + pub const ALL: [DriverClass; 3] = [ + DriverClass::Embedded, + DriverClass::External, + DriverClass::Null, + ]; + + /// Stable snake_case identifier used in config, on the wire, and in logs. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Embedded => "embedded", + Self::External => "external", + Self::Null => "null", + } + } + + /// Parse back from the config / wire form. + /// + /// # Errors + /// + /// Returns the unrecognised input in the message, so a typo in a + /// `class = …` line is self-explaining. + pub fn parse(raw: &str) -> Result { + Self::ALL + .iter() + .copied() + .find(|class| class.as_str() == raw) + .ok_or_else(|| DriverClassParseError::Unknown { + raw: raw.to_string(), + }) + } +} + +impl fmt::Display for DriverClass { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for DriverClass { + type Err = DriverClassParseError; + + fn from_str(raw: &str) -> Result { + Self::parse(raw) + } +} diff --git a/src/registry/mod.rs b/src/registry/mod.rs new file mode 100644 index 0000000..37b4714 --- /dev/null +++ b/src/registry/mod.rs @@ -0,0 +1,290 @@ +//! Driver admission: deciding, from configuration alone, which memory driver a +//! host may bind and what class it binds as. +//! +//! ## Why this lives in the crate and not in the host +//! +//! Admission is the one part of binding that is genuinely engine-neutral. It +//! answers "is this driver id real, and is it allowed to answer for memory" — +//! a question with the same correct answer for every host that embeds this +//! contract. The host keeps everything downstream of the decision: constructing +//! the provider, caching it per workspace, wrapping it in a policy guard, and +//! converting [`DriverClass`] into whatever generic subsystem vocabulary the +//! host uses for its other subsystems. +//! +//! ## The two rules worth stating out loud +//! +//! **A built-in id's class is fixed.** [`DriverRegistry::builtin`] reserves +//! `null` as [`DriverClass::Null`] and `tinycortex` as +//! [`DriverClass::Embedded`], and an explicit `class` line may *confirm* a +//! reserved id's class but never override it. Without that rule, a config +//! naming `driver = "null"` with `class = "embedded"` would build the real +//! engine, advertise every family, and persist memory under the id documented +//! as `/dev/null`; the inverse would label a store-nothing provider +//! `tinycortex`. Either way the bound engine is mislabelled. +//! +//! **An unknown id is refused, not guessed.** A driver needs no per-driver +//! config entry — the embedded default's options live elsewhere — but only a +//! reserved id is admitted implicitly. Anything else is a typo, or an external +//! backend that forgot its entry, and admitting it would silently run the +//! default engine under an invented driver id. +//! +//! ## Refusal is not failure +//! +//! [`DriverRegistry::admit`] returns [`FallbackReason`] rather than an error +//! type, because the caller is expected to *fall back and stay bound*, loudly, +//! rather than leave the memory slot empty. The reason string is operator-facing +//! — logged, published, rendered in status — so it must never interpolate a +//! credential reference or an endpoint from the driver's config entry. Callers +//! pass only [`DriverEntry`], which carries neither. + +use std::collections::BTreeMap; +use std::fmt; + +mod class; + +pub use class::{DriverClass, DriverClassParseError}; + +#[cfg(test)] +#[path = "test.rs"] +mod test; + +/// The driver id reserved for the null placeholder, re-exported from the +/// contract so hosts and adapters agree on the spelling. +pub use tinymemory_api::null::NULL_DRIVER_ID; + +/// The driver id of the bundled TinyCortex embedded engine. +/// +/// Lives here rather than in the adapter crate so admission can reserve the id +/// without depending on the adapter — a host that compiles the adapter out must +/// still refuse to bind something *else* under this name. +pub const TINYCORTEX_DRIVER_ID: &str = "tinycortex"; + +/// The trust state a driver entry must carry for an external class to bind. +pub const TRUSTED: &str = "trusted"; + +/// Why a bind fell back to the placeholder driver. +/// +/// `reason` is operator-facing: it is logged, published, and rendered in status. +/// It is built only from the driver id and the shape of the configuration, never +/// from a credential reference or an endpoint. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FallbackReason { + /// The driver id that was asked for. + pub configured_driver: String, + /// Why it was refused. + pub reason: String, +} + +impl fmt::Display for FallbackReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "driver '{}' refused: {}", + self.configured_driver, self.reason + ) + } +} + +/// A driver that was admitted, and the class it binds as. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Admission { + /// The id that bound. Equal to the configured id — admission never renames + /// a driver; a fallback is signalled by returning [`FallbackReason`]. + pub id: String, + /// How the driver is reached. + pub class: DriverClass, +} + +/// A host's per-driver configuration entry, reduced to the two fields admission +/// actually reads. +/// +/// Deliberately borrowed and deliberately narrow: a host's real entry type also +/// carries an endpoint and a credential reference, and neither may reach a +/// refusal message. Passing a projection rather than the whole entry makes that +/// structural instead of a rule someone has to remember. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct DriverEntry<'a> { + /// The `class` line, if the entry has one. + pub class: Option<&'a str>, + /// The entry's trust state. Only consulted for an external class. + pub trust_state: &'a str, +} + +/// The configuration paths quoted back to the operator in refusal messages. +/// +/// The crate does not know what a host's config file looks like, but a refusal +/// that cannot name the block to edit is much less useful. The host supplies +/// its own spellings; the wording around them is fixed here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConfigLabels<'a> { + /// The memory subsystem block, e.g. `[subsystems.memory]`. + pub section: &'a str, + /// The driver table, e.g. `[subsystems.memory.drivers]`. + pub drivers: &'a str, + /// One driver's entry, e.g. `[subsystems.memory.drivers.]`. + pub driver_entry: &'a str, +} + +impl Default for ConfigLabels<'static> { + fn default() -> Self { + Self { + section: "[subsystems.memory]", + drivers: "[subsystems.memory.drivers]", + driver_entry: "[subsystems.memory.drivers.]", + } + } +} + +/// The set of driver ids whose class is fixed by the crate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DriverRegistry { + reserved: BTreeMap, +} + +impl Default for DriverRegistry { + fn default() -> Self { + Self::builtin() + } +} + +impl DriverRegistry { + /// The registry every host starts from: `null` and `tinycortex`. + #[must_use] + pub fn builtin() -> Self { + let mut reserved = BTreeMap::new(); + reserved.insert(NULL_DRIVER_ID.to_string(), DriverClass::Null); + reserved.insert(TINYCORTEX_DRIVER_ID.to_string(), DriverClass::Embedded); + Self { reserved } + } + + /// A registry reserving nothing. Every id then needs an explicit `class`. + #[must_use] + pub fn empty() -> Self { + Self { + reserved: BTreeMap::new(), + } + } + + /// Reserve an additional driver id at a fixed class. + /// + /// For a host that bundles an adapter this crate does not know about. The + /// same confirm-never-override rule then applies to it. + #[must_use] + pub fn with_reserved(mut self, id: impl Into, class: DriverClass) -> Self { + self.reserved.entry(id.into()).or_insert(class); + self + } + + /// The class `id` is fixed to, or `None` if the id is not reserved. + #[must_use] + pub fn reserved_class(&self, id: &str) -> Option { + self.reserved.get(id).copied() + } + + /// Decide whether the configured driver may bind. + /// + /// Pure — no I/O, no globals — so the fail-closed trust rule is testable + /// without booting anything. + /// + /// `entry` is the host's `drivers.` entry, or `None` when the config has + /// no entry for this id. + /// + /// # Errors + /// + /// Returns the [`FallbackReason`] to record and publish when the configured + /// driver is refused. Callers are expected to fall back rather than fail: + /// the subsystem must stay bound, loudly. + pub fn admit( + &self, + driver: &str, + entry: Option>, + labels: ConfigLabels<'_>, + ) -> Result { + let id = driver.trim(); + if id.is_empty() { + return Err(FallbackReason { + configured_driver: String::new(), + reason: format!("{} driver is empty", labels.section), + }); + } + + let refuse = |reason: &str| FallbackReason { + configured_driver: id.to_string(), + reason: reason.to_string(), + }; + + // A driver needs no entry: the embedded default's options live in the + // host's own config blocks. But only a reserved id is admitted + // implicitly — see the module docs. + let Some(entry) = entry else { + return self.implicit(id, &refuse, &format!("no {} entry", labels.driver_entry)); + }; + + let admission = match entry.class { + None => self.implicit( + id, + &refuse, + &format!("{} has no class line", labels.driver_entry), + )?, + Some(raw) => { + let class = DriverClass::parse(raw).map_err(|_| refuse("unknown driver class"))?; + // A reserved id names a fixed implementation, so an explicit + // `class` line may confirm it but never override it. + if let Some(fixed) = self.reserved_class(id) { + if class != fixed { + return Err(refuse(&format!( + "driver id \"{id}\" is built in and is always class \ + \"{}\"; remove the conflicting class = \"{raw}\" line", + fixed.as_str() + ))); + } + } + Admission { + id: id.to_string(), + class, + } + } + }; + + if admission.class == DriverClass::External { + // Fail closed: trust must be explicitly raised before an + // out-of-process driver is allowed to answer for memory. + if entry.trust_state != TRUSTED { + return Err(refuse(&format!( + "external driver is untrusted: set trust_state = \"{TRUSTED}\" \ + under {} to allow this binding", + labels.drivers + ))); + } + // A distinct reason string from the trust refusal above, so a test + // for the trust rule cannot pass for the wrong reason. + return Err(refuse( + "external driver transport is not implemented yet (the http adapter lands in M4)", + )); + } + + Ok(admission) + } + + /// The class an id implies when nothing says otherwise. + /// + /// `context` names which part of the config was missing; the refusal echoes + /// it so the operator knows whether to add an entry or a `class` line. + fn implicit( + &self, + id: &str, + refuse: &impl Fn(&str) -> FallbackReason, + context: &str, + ) -> Result { + match self.reserved_class(id) { + Some(class) => Ok(Admission { + id: id.to_string(), + class, + }), + None => Err(refuse(&format!( + "unknown driver id \"{id}\": {context}, and the id is neither the \ + embedded default nor \"null\"" + ))), + } + } +} diff --git a/src/registry/test.rs b/src/registry/test.rs new file mode 100644 index 0000000..d6db7f3 --- /dev/null +++ b/src/registry/test.rs @@ -0,0 +1,257 @@ +//! Admission tests. +//! +//! These mirror the host-side binding tests that guarded this logic before it +//! moved into the crate, so a refusal that used to be caught in OpenHuman is +//! still caught here. + +// A failing assertion in a test *is* a panic; the crate-wide `expect_used` / +// `panic` lints exist to keep the library from panicking, not the tests. +#![allow(clippy::expect_used)] + +use super::*; + +fn labels() -> ConfigLabels<'static> { + ConfigLabels::default() +} + +fn entry(class: Option<&'static str>, trust_state: &'static str) -> DriverEntry<'static> { + DriverEntry { class, trust_state } +} + +#[test] +fn the_embedded_default_admits_without_an_entry() { + let admitted = DriverRegistry::builtin() + .admit(TINYCORTEX_DRIVER_ID, None, labels()) + .expect("the embedded default admits"); + assert_eq!(admitted.id, TINYCORTEX_DRIVER_ID); + assert_eq!(admitted.class, DriverClass::Embedded); +} + +#[test] +fn the_null_placeholder_admits_without_an_entry() { + let admitted = DriverRegistry::builtin() + .admit(NULL_DRIVER_ID, None, labels()) + .expect("null admits"); + assert_eq!(admitted.class, DriverClass::Null); +} + +#[test] +fn an_empty_driver_id_is_refused_and_names_the_config_section() { + let refusal = DriverRegistry::builtin() + .admit(" ", None, labels()) + .expect_err("an empty driver id is refused"); + assert_eq!(refusal.configured_driver, ""); + assert_eq!(refusal.reason, "[subsystems.memory] driver is empty"); +} + +#[test] +fn an_unknown_id_without_an_entry_is_refused_rather_than_guessed() { + let refusal = DriverRegistry::builtin() + .admit("supermemory", None, labels()) + .expect_err("an unknown id without an entry is refused"); + assert_eq!(refusal.configured_driver, "supermemory"); + assert!( + refusal.reason.contains("unknown driver id \"supermemory\""), + "reason should name the id: {}", + refusal.reason + ); + assert!( + refusal + .reason + .contains("no [subsystems.memory.drivers.] entry"), + "reason should name the missing block: {}", + refusal.reason + ); +} + +#[test] +fn an_unknown_id_with_a_classless_entry_is_refused() { + let refusal = DriverRegistry::builtin() + .admit("supermemory", Some(entry(None, TRUSTED)), labels()) + .expect_err("a classless entry cannot admit an arbitrary id"); + assert!( + refusal.reason.contains("has no class line"), + "reason should name the missing class line: {}", + refusal.reason + ); +} + +#[test] +fn an_unparseable_class_is_refused_with_the_raw_value() { + let refusal = DriverRegistry::builtin() + .admit( + "supermemory", + Some(entry(Some("emebdded"), TRUSTED)), + labels(), + ) + .expect_err("a misspelled class is refused"); + assert_eq!(refusal.reason, "unknown driver class"); +} + +/// The rule that keeps a bound engine truthfully labelled: a reserved id's +/// class may be confirmed by an explicit line, never overridden by one. +#[test] +fn a_class_override_cannot_smuggle_the_engine_in_under_the_null_id() { + let refusal = DriverRegistry::builtin() + .admit( + NULL_DRIVER_ID, + Some(entry(Some("embedded"), TRUSTED)), + labels(), + ) + .expect_err("null is always class null"); + assert!( + refusal + .reason + .contains("is built in and is always class \"null\""), + "reason should state the fixed class: {}", + refusal.reason + ); + assert!( + refusal.reason.contains("class = \"embedded\""), + "reason should quote the conflicting line: {}", + refusal.reason + ); +} + +#[test] +fn a_class_override_cannot_relabel_the_engine_as_null() { + let refusal = DriverRegistry::builtin() + .admit( + TINYCORTEX_DRIVER_ID, + Some(entry(Some("null"), TRUSTED)), + labels(), + ) + .expect_err("the embedded default is always class embedded"); + assert!( + refusal + .reason + .contains("is built in and is always class \"embedded\""), + "reason should state the fixed class: {}", + refusal.reason + ); +} + +#[test] +fn a_confirming_class_line_is_accepted() { + let admitted = DriverRegistry::builtin() + .admit( + TINYCORTEX_DRIVER_ID, + Some(entry(Some("embedded"), TRUSTED)), + labels(), + ) + .expect("a class line confirming the fixed class is fine"); + assert_eq!(admitted.class, DriverClass::Embedded); +} + +#[test] +fn an_untrusted_external_driver_is_refused_for_trust() { + let refusal = DriverRegistry::builtin() + .admit( + "remote", + Some(entry(Some("external"), "untrusted")), + labels(), + ) + .expect_err("an untrusted external driver is refused"); + assert!( + refusal.reason.contains("external driver is untrusted"), + "reason should be the trust refusal: {}", + refusal.reason + ); + assert!( + refusal + .reason + .contains("under [subsystems.memory.drivers] to allow this binding"), + "reason should name the block to edit: {}", + refusal.reason + ); +} + +/// A trusted external driver is still refused, but for a *different* reason — +/// so the trust test above cannot pass for the wrong reason. +#[test] +fn a_trusted_external_driver_is_refused_for_transport_not_trust() { + let refusal = DriverRegistry::builtin() + .admit("remote", Some(entry(Some("external"), TRUSTED)), labels()) + .expect_err("external transport does not exist yet"); + assert!( + refusal.reason.contains("transport is not implemented yet"), + "reason should be the transport refusal: {}", + refusal.reason + ); + assert!( + !refusal.reason.contains("untrusted"), + "the transport refusal must be distinct from the trust refusal: {}", + refusal.reason + ); +} + +#[test] +fn a_host_can_reserve_an_additional_driver_id() { + let registry = DriverRegistry::builtin().with_reserved("mem0", DriverClass::Embedded); + let admitted = registry + .admit("mem0", None, labels()) + .expect("a host-reserved id admits implicitly"); + assert_eq!(admitted.class, DriverClass::Embedded); + + let refusal = registry + .admit("mem0", Some(entry(Some("null"), TRUSTED)), labels()) + .expect_err("the confirm-never-override rule applies to host-reserved ids too"); + assert!(refusal.reason.contains("is built in and is always class")); +} + +#[test] +fn an_empty_registry_reserves_nothing() { + let refusal = DriverRegistry::empty() + .admit(TINYCORTEX_DRIVER_ID, None, labels()) + .expect_err("nothing is reserved"); + assert!(refusal.reason.contains("unknown driver id")); +} + +/// A refusal is rendered to operators, so it must carry only the id and the +/// shape of the config — never anything that could hold a secret. The type +/// system does most of the work here ([`DriverEntry`] carries no endpoint and no +/// credential reference); this pins the remaining gap, which is the id itself. +#[test] +fn a_refusal_reason_carries_only_the_id_and_config_shape() { + let refusal = DriverRegistry::builtin() + .admit( + "remote", + Some(entry(Some("external"), "untrusted")), + labels(), + ) + .expect_err("refused"); + assert!(!refusal.reason.contains("http"), "no endpoint may appear"); + assert!( + !refusal.reason.contains("token"), + "no credential may appear" + ); + assert!( + !refusal.reason.contains("secret"), + "no credential may appear" + ); +} + +#[test] +fn driver_class_round_trips_through_its_config_form() { + for class in DriverClass::ALL { + assert_eq!( + DriverClass::parse(class.as_str()).expect("round trip"), + class + ); + assert_eq!(class.to_string(), class.as_str()); + } + assert_eq!( + DriverClass::parse("nope").expect_err("rejected"), + DriverClassParseError::Unknown { raw: "nope".into() } + ); +} + +/// The serde form is the config form. A host reads these out of a TOML file, so +/// a rename here would silently invalidate deployed configuration. +#[test] +fn driver_class_serde_matches_the_config_spelling() { + for class in DriverClass::ALL { + let json = serde_json::to_string(&class).expect("serialize"); + assert_eq!(json, format!("\"{}\"", class.as_str())); + } +} diff --git a/tests/public_api.rs b/tests/public_api.rs deleted file mode 100644 index 4ee1e4b..0000000 --- a/tests/public_api.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Integration tests for the public crate surface. -//! -//! These tests link against the crate as a downstream consumer would: they can -//! only use what `src/lib.rs` re-exports. Treat them as the regression suite -//! for the crate's public contract — if a change breaks a test here, it is a -//! breaking change for users. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use rust_template::{Error, greet}; - -#[test] -fn greeting_is_available_to_consumers() { - assert_eq!(greet("Rust").unwrap(), "Hello, Rust!"); -} - -#[test] -fn errors_are_available_to_consumers() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); -} diff --git a/tmp b/tmp new file mode 100755 index 0000000..9cd4ca7 Binary files /dev/null and b/tmp differ diff --git a/vendor/tinycortex b/vendor/tinycortex new file mode 160000 index 0000000..bf37602 --- /dev/null +++ b/vendor/tinycortex @@ -0,0 +1 @@ +Subproject commit bf3760213fbae5e4cacf18c4a495930815e33feb