diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0c61763..5811a3b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,4 +1,4 @@ -# Copyright (c) 2025-2026 Loctree (https://loct.io) +# Copyright (c) 2025-2026 Loctree (https://vetcoders.io) name: Bug Report description: Report a reproducible problem in rust-memex title: "[Bug]: " diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 2b20760..0d5c876 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,4 +1,4 @@ -# Copyright (c) 2025-2026 Loctree (https://loct.io) +# Copyright (c) 2025-2026 Loctree (https://vetcoders.io) name: Feature Request description: Suggest an improvement or new capability for rust-memex title: "[Feature]: " diff --git a/.gitignore b/.gitignore index 67dbb06..203ed93 100644 --- a/.gitignore +++ b/.gitignore @@ -14,24 +14,31 @@ target/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ /.fastembed_cache # Agent workspace directories -/.ai-agents/ -/.claude/ -/.junie/ +#/.ai-agents/ +#/.claude/ +#/.junie/ /.lancedb/ -/.loctree +/.loctree/ AGENTS.md -/lancedb /*.py -/tmp -/.vibecrafted +/tmp/ +/.vibecrafted/ +/.*/ +!.github/ + # Environment Variables .env .env.* !.env.example *.out +/*.md +/docs/*.md +!README.md +!SECURITY.md +!CHANGELOG.md +!CONTRIBUTING.md \ No newline at end of file diff --git a/.vibecrafted/plans b/.vibecrafted/plans deleted file mode 120000 index 5e00b20..0000000 --- a/.vibecrafted/plans +++ /dev/null @@ -1 +0,0 @@ -/Users/polyversai/.vibecrafted/artifacts/VetCoders/rmcp-memex/2026_0408/marbles/plans \ No newline at end of file diff --git a/.vibecrafted/reports b/.vibecrafted/reports deleted file mode 120000 index d843b97..0000000 --- a/.vibecrafted/reports +++ /dev/null @@ -1 +0,0 @@ -/Users/polyversai/.vibecrafted/artifacts/VetCoders/rmcp-memex/2026_0408/marbles/reports \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index be09709..07bd260 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "aicx-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5035ce698c57cc617cb3f035dee6987d456770516213745742758cd941d274ed" +dependencies = [ + "anyhow", + "chrono", + "regex", + "serde", + "serde_json", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -146,6 +159,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -160,9 +185,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "arrow" -version = "56.2.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e833808ff2d94ed40d9379848a950d995043c7fb3e81a30b383f4c6033821cc" +checksum = "3bd47f2a6ddc39244bd722a27ee5da66c03369d087b9e024eafdb03e98b98ea7" dependencies = [ "arrow-arith", "arrow-array", @@ -181,23 +206,23 @@ dependencies = [ [[package]] name = "arrow-arith" -version = "56.2.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad08897b81588f60ba983e3ca39bda2b179bdd84dced378e7df81a5313802ef8" +checksum = "7c7bbd679c5418b8639b92be01f361d60013c4906574b578b77b63c78356594c" dependencies = [ "arrow-array", "arrow-buffer", "arrow-data", "arrow-schema", "chrono", - "num", + "num-traits", ] [[package]] name = "arrow-array" -version = "56.2.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8548ca7c070d8db9ce7aa43f37393e4bfcf3f2d3681df278490772fd1673d08d" +checksum = "c8a4ab47b3f3eac60f7fd31b81e9028fda018607bcc63451aca4f2b755269862" dependencies = [ "ahash", "arrow-buffer", @@ -207,46 +232,50 @@ dependencies = [ "chrono-tz", "half", "hashbrown 0.16.1", - "num", + "num-complex", + "num-integer", + "num-traits", ] [[package]] name = "arrow-buffer" -version = "56.2.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e003216336f70446457e280807a73899dd822feaf02087d31febca1363e2fccc" +checksum = "0d18b89b4c4f4811d0858175e79541fe98e33e18db3b011708bc287b1240593f" dependencies = [ "bytes", "half", - "num", + "num-bigint", + "num-traits", ] [[package]] name = "arrow-cast" -version = "56.2.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "919418a0681298d3a77d1a315f625916cb5678ad0d74b9c60108eb15fd083023" +checksum = "722b5c41dd1d14d0a879a1bce92c6fe33f546101bb2acce57a209825edd075b3" dependencies = [ "arrow-array", "arrow-buffer", "arrow-data", + "arrow-ord", "arrow-schema", "arrow-select", "atoi", - "base64", + "base64 0.22.1", "chrono", "comfy-table", "half", "lexical-core", - "num", + "num-traits", "ryu", ] [[package]] name = "arrow-csv" -version = "56.2.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa9bf02705b5cf762b6f764c65f04ae9082c7cfc4e96e0c33548ee3f67012eb" +checksum = "27ddb80a4848e03b1655af496d5ac2563a779e5742fcb48f2ca2e089c9cd2197" dependencies = [ "arrow-array", "arrow-cast", @@ -259,21 +288,22 @@ dependencies = [ [[package]] name = "arrow-data" -version = "56.2.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5c64fff1d142f833d78897a772f2e5b55b36cb3e6320376f0961ab0db7bd6d0" +checksum = "c1683705c63dcf0d18972759eda48489028cbbff67af7d6bef2c6b7b74ab778a" dependencies = [ "arrow-buffer", "arrow-schema", "half", - "num", + "num-integer", + "num-traits", ] [[package]] name = "arrow-ipc" -version = "56.2.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3594dcddccc7f20fd069bc8e9828ce37220372680ff638c5e00dea427d88f5" +checksum = "8cf72d04c07229fbf4dbebe7145cac37d7cf7ec582fe705c6b92cb314af096ab" dependencies = [ "arrow-array", "arrow-buffer", @@ -281,15 +311,15 @@ dependencies = [ "arrow-schema", "arrow-select", "flatbuffers", - "lz4_flex", + "lz4_flex 0.12.2", "zstd", ] [[package]] name = "arrow-json" -version = "56.2.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88cf36502b64a127dc659e3b305f1d993a544eab0d48cce704424e62074dc04b" +checksum = "a84a905f41fedfcd7679813c89a61dc369c0f932b27aa8dcc6aa051cc781a97d" dependencies = [ "arrow-array", "arrow-buffer", @@ -299,19 +329,21 @@ dependencies = [ "chrono", "half", "indexmap 2.14.0", + "itoa", "lexical-core", "memchr", - "num", - "serde", + "num-traits", + "ryu", + "serde_core", "serde_json", "simdutf8", ] [[package]] name = "arrow-ord" -version = "56.2.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c8f82583eb4f8d84d4ee55fd1cb306720cddead7596edce95b50ee418edf66f" +checksum = "082342947d4e5a2bcccf029a0a0397e21cb3bb8421edd9571d34fb5dd2670256" dependencies = [ "arrow-array", "arrow-buffer", @@ -322,9 +354,9 @@ dependencies = [ [[package]] name = "arrow-row" -version = "56.2.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d07ba24522229d9085031df6b94605e0f4b26e099fb7cdeec37abd941a73753" +checksum = "e3a931b520a2a5e22033e01a6f2486b4cdc26f9106b759abeebc320f125e94d7" dependencies = [ "arrow-array", "arrow-buffer", @@ -335,34 +367,34 @@ dependencies = [ [[package]] name = "arrow-schema" -version = "56.2.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3aa9e59c611ebc291c28582077ef25c97f1975383f1479b12f3b9ffee2ffabe" +checksum = "e4cf0d4a6609679e03002167a61074a21d7b1ad9ea65e462b2c0a97f8a3b2bc6" dependencies = [ "bitflags", - "serde", + "serde_core", "serde_json", ] [[package]] name = "arrow-select" -version = "56.2.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c41dbbd1e97bfcaee4fcb30e29105fb2c75e4d82ae4de70b792a5d3f66b2e7a" +checksum = "0b320d86a9806923663bb0fd9baa65ecaba81cb0cd77ff8c1768b9716b4ef891" dependencies = [ "ahash", "arrow-array", "arrow-buffer", "arrow-data", "arrow-schema", - "num", + "num-traits", ] [[package]] name = "arrow-string" -version = "56.2.0" +version = "57.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53f5183c150fbc619eede22b861ea7c0eebed8eaac0333eaa7f6da5205fd504d" +checksum = "b493e99162e5764077e7823e50ba284858d365922631c7aaefe9487b1abd02c2" dependencies = [ "arrow-array", "arrow-buffer", @@ -370,7 +402,7 @@ dependencies = [ "arrow-schema", "arrow-select", "memchr", - "num", + "num-traits", "regex", "regex-syntax", ] @@ -389,9 +421,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" dependencies = [ "compression-codecs", "compression-core", @@ -480,15 +512,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -503,6 +535,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -536,12 +569,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bigdecimal" version = "0.4.10" @@ -557,9 +608,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bitpacking" @@ -593,9 +644,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.4" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" dependencies = [ "arrayref", "arrayvec", @@ -650,9 +701,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -661,19 +712,28 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytecount" @@ -725,9 +785,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", @@ -795,9 +855,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -817,9 +877,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -841,20 +901,19 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "comfy-table" -version = "7.1.2" +version = "7.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0d05af1e006a2407bedef5af410552494ce5be9090444dbbcb57258c1af3d56" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" dependencies = [ - "strum", - "strum_macros", + "unicode-segmentation", "unicode-width 0.2.0", ] [[package]] name = "compact_str" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" dependencies = [ "castaway", "cfg-if", @@ -866,9 +925,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.37" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ "compression-core", "flate2", @@ -877,9 +936,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" [[package]] name = "concurrent-queue" @@ -903,6 +962,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-random" version = "0.1.18" @@ -1018,6 +1083,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1073,6 +1148,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1104,6 +1191,33 @@ dependencies = [ "memchr", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -1140,9 +1254,9 @@ dependencies = [ [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -1154,12 +1268,11 @@ dependencies = [ [[package]] name = "datafusion" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af15bb3c6ffa33011ef579f6b0bcbe7c26584688bd6c994f548e44df67f011a" +checksum = "7541353e77dc7262b71ca27be07d8393661737e3a73b5d1b1c6f7d814c64fa2a" dependencies = [ "arrow", - "arrow-ipc", "arrow-schema", "async-trait", "bytes", @@ -1169,6 +1282,7 @@ dependencies = [ "datafusion-common", "datafusion-common-runtime", "datafusion-datasource", + "datafusion-datasource-arrow", "datafusion-datasource-csv", "datafusion-datasource-json", "datafusion-execution", @@ -1192,7 +1306,7 @@ dependencies = [ "log", "object_store", "parking_lot", - "rand 0.9.2", + "rand 0.9.4", "regex", "sqlparser", "tempfile", @@ -1203,9 +1317,9 @@ dependencies = [ [[package]] name = "datafusion-catalog" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187622262ad8f7d16d3be9202b4c1e0116f1c9aa387e5074245538b755261621" +checksum = "9997731f90fa5398ef831ad0e69600f92c861b79c0d38bd1a29b6f0e3a0ce4c8" dependencies = [ "arrow", "async-trait", @@ -1218,7 +1332,6 @@ dependencies = [ "datafusion-physical-expr", "datafusion-physical-plan", "datafusion-session", - "datafusion-sql", "futures", "itertools 0.14.0", "log", @@ -1229,9 +1342,9 @@ dependencies = [ [[package]] name = "datafusion-catalog-listing" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9657314f0a32efd0382b9a46fdeb2d233273ece64baa68a7c45f5a192daf0f83" +checksum = "2b30a3dd50dec860c9559275c8d97d9de602e611237a6ecfbda0b3b63b872352" dependencies = [ "arrow", "async-trait", @@ -1241,28 +1354,27 @@ dependencies = [ "datafusion-execution", "datafusion-expr", "datafusion-physical-expr", + "datafusion-physical-expr-adapter", "datafusion-physical-expr-common", "datafusion-physical-plan", - "datafusion-session", "futures", + "itertools 0.14.0", "log", "object_store", - "tokio", ] [[package]] name = "datafusion-common" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a83760d9a13122d025fbdb1d5d5aaf93dd9ada5e90ea229add92aa30898b2d1" +checksum = "d551054acec0398ca604512310b77ce05c46f66e54b54d48200a686e385cca4e" dependencies = [ "ahash", "arrow", "arrow-ipc", - "base64", "chrono", "half", - "hashbrown 0.14.5", + "hashbrown 0.16.1", "indexmap 2.14.0", "libc", "log", @@ -1275,9 +1387,9 @@ dependencies = [ [[package]] name = "datafusion-common-runtime" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b6234a6c7173fe5db1c6c35c01a12b2aa0f803a3007feee53483218817f8b1e" +checksum = "567d40e285f5b79f8737b576605721cd6c1133b5d2b00bdbd5d9838d90d0812f" dependencies = [ "futures", "log", @@ -1286,9 +1398,9 @@ dependencies = [ [[package]] name = "datafusion-datasource" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7256c9cb27a78709dd42d0c80f0178494637209cac6e29d5c93edd09b6721b86" +checksum = "27d2668f51b3b30befae2207472569e37807fdedd1d14da58acc6f8ca6257eae" dependencies = [ "arrow", "async-trait", @@ -1308,27 +1420,49 @@ dependencies = [ "itertools 0.14.0", "log", "object_store", - "rand 0.9.2", + "rand 0.9.4", "tokio", "url", ] +[[package]] +name = "datafusion-datasource-arrow" +version = "52.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02e1b3e3a8ec55f1f62de4252b0407c8567363d056078769a197e24fc834a0f" +dependencies = [ + "arrow", + "arrow-ipc", + "async-trait", + "bytes", + "datafusion-common", + "datafusion-common-runtime", + "datafusion-datasource", + "datafusion-execution", + "datafusion-expr", + "datafusion-physical-expr-common", + "datafusion-physical-plan", + "datafusion-session", + "futures", + "itertools 0.14.0", + "object_store", + "tokio", +] + [[package]] name = "datafusion-datasource-csv" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64533a90f78e1684bfb113d200b540f18f268134622d7c96bbebc91354d04825" +checksum = "b559d7bf87d4f900f847baba8509634f838d9718695389e903604cdcccdb01f3" dependencies = [ "arrow", "async-trait", "bytes", - "datafusion-catalog", "datafusion-common", "datafusion-common-runtime", "datafusion-datasource", "datafusion-execution", "datafusion-expr", - "datafusion-physical-expr", "datafusion-physical-expr-common", "datafusion-physical-plan", "datafusion-session", @@ -1340,43 +1474,41 @@ dependencies = [ [[package]] name = "datafusion-datasource-json" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d7ebeb12c77df0aacad26f21b0d033aeede423a64b2b352f53048a75bf1d6e6" +checksum = "250e2d7591ba8b638f063854650faa40bca4e8bd4059b2ece8836f6388d02db4" dependencies = [ "arrow", "async-trait", "bytes", - "datafusion-catalog", "datafusion-common", "datafusion-common-runtime", "datafusion-datasource", "datafusion-execution", "datafusion-expr", - "datafusion-physical-expr", "datafusion-physical-expr-common", "datafusion-physical-plan", "datafusion-session", "futures", "object_store", - "serde_json", "tokio", ] [[package]] name = "datafusion-doc" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99ee6b1d9a80d13f9deb2291f45c07044b8e62fb540dbde2453a18be17a36429" +checksum = "b9496cb0db222dbb9a3735760ceca7fc56f35e1d5502c38d0caa77a81e9c1f6a" [[package]] name = "datafusion-execution" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4cec0a57653bec7b933fb248d3ffa3fa3ab3bd33bd140dc917f714ac036f531" +checksum = "dc45d23c516ed8d3637751e44e09e21b45b3f58b473c802dddd1f1ad4fe435ff" dependencies = [ "arrow", "async-trait", + "chrono", "dashmap", "datafusion-common", "datafusion-expr", @@ -1384,16 +1516,16 @@ dependencies = [ "log", "object_store", "parking_lot", - "rand 0.9.2", + "rand 0.9.4", "tempfile", "url", ] [[package]] name = "datafusion-expr" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef76910bdca909722586389156d0aa4da4020e1631994d50fadd8ad4b1aa05fe" +checksum = "63dd30526d2db4fda6440806a41e4676334a94bc0596cc9cc2a0efed20ef2c44" dependencies = [ "arrow", "async-trait", @@ -1405,6 +1537,7 @@ dependencies = [ "datafusion-functions-window-common", "datafusion-physical-expr-common", "indexmap 2.14.0", + "itertools 0.14.0", "paste", "serde_json", "sqlparser", @@ -1412,9 +1545,9 @@ dependencies = [ [[package]] name = "datafusion-expr-common" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d155ccbda29591ca71a1344dd6bed26c65a4438072b400df9db59447f590bb6" +checksum = "1b486b5f6255d40976b88bb83813b0d035a8333e0ec39864824e78068cf42fa6" dependencies = [ "arrow", "datafusion-common", @@ -1425,16 +1558,17 @@ dependencies = [ [[package]] name = "datafusion-functions" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de2782136bd6014670fd84fe3b0ca3b3e4106c96403c3ae05c0598577139977" +checksum = "07356c94118d881130dd0ffbff127540407d969c8978736e324edcd6c41cd48f" dependencies = [ "arrow", "arrow-buffer", - "base64", + "base64 0.22.1", "blake2", "blake3", "chrono", + "chrono-tz", "datafusion-common", "datafusion-doc", "datafusion-execution", @@ -1445,7 +1579,8 @@ dependencies = [ "itertools 0.14.0", "log", "md-5", - "rand 0.9.2", + "num-traits", + "rand 0.9.4", "regex", "sha2", "unicode-segmentation", @@ -1454,9 +1589,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07331fc13603a9da97b74fd8a273f4238222943dffdbbed1c4c6f862a30105bf" +checksum = "b644f9cf696df9233ce6958b9807666d78563b56f923267474dd6c07795f1f8f" dependencies = [ "ahash", "arrow", @@ -1475,9 +1610,9 @@ dependencies = [ [[package]] name = "datafusion-functions-aggregate-common" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5951e572a8610b89968a09b5420515a121fbc305c0258651f318dc07c97ab17" +checksum = "c1de2deaaabe8923ce9ea9f29c47bbb4ee14f67ea2fe1ab5398d9bbebcf86e56" dependencies = [ "ahash", "arrow", @@ -1488,9 +1623,9 @@ dependencies = [ [[package]] name = "datafusion-functions-nested" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdacca9302c3d8fc03f3e94f338767e786a88a33f5ebad6ffc0e7b50364b9ea3" +checksum = "552f8d92e4331ee91d23c02d12bb6acf32cbfd5215117e01c0fb63cd4b15af1a" dependencies = [ "arrow", "arrow-ord", @@ -1498,6 +1633,7 @@ dependencies = [ "datafusion-doc", "datafusion-execution", "datafusion-expr", + "datafusion-expr-common", "datafusion-functions", "datafusion-functions-aggregate", "datafusion-functions-aggregate-common", @@ -1510,9 +1646,9 @@ dependencies = [ [[package]] name = "datafusion-functions-table" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c37ff8a99434fbbad604a7e0669717c58c7c4f14c472d45067c4b016621d981" +checksum = "970fd0cdd3df8802b9a9975ff600998289ba9d46682a4f7285cba4820c9ada78" dependencies = [ "arrow", "async-trait", @@ -1526,9 +1662,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e2aea7c79c926cffabb13dc27309d4eaeb130f4a21c8ba91cdd241c813652b" +checksum = "40b4c21a7c8a986a1866c0a87ab756d0bbf7b5f41f306009fa2d9af79c52ed31" dependencies = [ "arrow", "datafusion-common", @@ -1544,9 +1680,9 @@ dependencies = [ [[package]] name = "datafusion-functions-window-common" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fead257ab5fd2ffc3b40fda64da307e20de0040fe43d49197241d9de82a487f" +checksum = "b1210ad73b8b3211aeaf4a42bef9bd7a2b7fce3ec119a478831f18c6ff7f7b93" dependencies = [ "datafusion-common", "datafusion-physical-expr-common", @@ -1554,20 +1690,20 @@ dependencies = [ [[package]] name = "datafusion-macros" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec6f637bce95efac05cdfb9b6c19579ed4aa5f6b94d951cfa5bb054b7bb4f730" +checksum = "aaa566a963013a38681ad82a727a654bc7feb19632426aea8c3412d415d200c5" dependencies = [ - "datafusion-expr", + "datafusion-doc", "quote", "syn 2.0.117", ] [[package]] name = "datafusion-optimizer" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6583ef666ae000a613a837e69e456681a9faa96347bf3877661e9e89e141d8a" +checksum = "ff9aa82b240252a88dee118372f9b9757c545ab9e53c0736bebab2e7da0ef1f2" dependencies = [ "arrow", "chrono", @@ -1584,9 +1720,9 @@ dependencies = [ [[package]] name = "datafusion-physical-expr" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8668103361a272cbbe3a61f72eca60c9b7c706e87cc3565bcf21e2b277b84f6" +checksum = "7d48022b8af9988c1d852644f9e8b5584c490659769a550c5e8d39457a1da0a5" dependencies = [ "ahash", "arrow", @@ -1596,20 +1732,20 @@ dependencies = [ "datafusion-functions-aggregate-common", "datafusion-physical-expr-common", "half", - "hashbrown 0.14.5", + "hashbrown 0.16.1", "indexmap 2.14.0", "itertools 0.14.0", - "log", "parking_lot", "paste", - "petgraph 0.8.3", + "petgraph", + "tokio", ] [[package]] name = "datafusion-physical-expr-adapter" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "815acced725d30601b397e39958e0e55630e0a10d66ef7769c14ae6597298bb0" +checksum = "ae7a8abc0b4fe624000972a9b145b30b7f1b680bffaa950ea53f78d9b21c27c3" dependencies = [ "arrow", "datafusion-common", @@ -1622,23 +1758,26 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-common" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6652fe7b5bf87e85ed175f571745305565da2c0b599d98e697bcbedc7baa47c3" +checksum = "147253ca3e6b9d59c162de64c02800973018660e13340dd1886dd038d17ac429" dependencies = [ "ahash", "arrow", + "chrono", "datafusion-common", "datafusion-expr-common", - "hashbrown 0.14.5", + "hashbrown 0.16.1", + "indexmap 2.14.0", "itertools 0.14.0", + "parking_lot", ] [[package]] name = "datafusion-physical-optimizer" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b7d623eb6162a3332b564a0907ba00895c505d101b99af78345f1acf929b5c" +checksum = "689156bb2282107b6239db8d7ef44b4dab10a9b33d3491a0c74acac5e4fedd72" dependencies = [ "arrow", "datafusion-common", @@ -1650,32 +1789,31 @@ dependencies = [ "datafusion-physical-plan", "datafusion-pruning", "itertools 0.14.0", - "log", ] [[package]] name = "datafusion-physical-plan" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2f7f778a1a838dec124efb96eae6144237d546945587557c9e6936b3414558c" +checksum = "68253dc0ee5330aa558b2549c9b0da5af9fc17d753ae73022939014ad616fc28" dependencies = [ "ahash", "arrow", "arrow-ord", "arrow-schema", "async-trait", - "chrono", "datafusion-common", "datafusion-common-runtime", "datafusion-execution", "datafusion-expr", + "datafusion-functions", "datafusion-functions-aggregate-common", "datafusion-functions-window-common", "datafusion-physical-expr", "datafusion-physical-expr-common", "futures", "half", - "hashbrown 0.14.5", + "hashbrown 0.16.1", "indexmap 2.14.0", "itertools 0.14.0", "log", @@ -1686,12 +1824,11 @@ dependencies = [ [[package]] name = "datafusion-pruning" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1e59e2ca14fe3c30f141600b10ad8815e2856caa59ebbd0e3e07cd3d127a65" +checksum = "0fcad240a54d0b1d3e8f668398900260a53122d522b2102ab57218590decacd6" dependencies = [ "arrow", - "arrow-schema", "datafusion-common", "datafusion-datasource", "datafusion-expr-common", @@ -1704,36 +1841,27 @@ dependencies = [ [[package]] name = "datafusion-session" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21ef8e2745583619bd7a49474e8f45fbe98ebb31a133f27802217125a7b3d58d" +checksum = "f58e83a68bb67007a8fcbf005c44cefe441270c7ee7f6dee10c0e0109b556f6d" dependencies = [ - "arrow", "async-trait", - "dashmap", "datafusion-common", - "datafusion-common-runtime", "datafusion-execution", "datafusion-expr", - "datafusion-physical-expr", "datafusion-physical-plan", - "datafusion-sql", - "futures", - "itertools 0.14.0", - "log", - "object_store", "parking_lot", - "tokio", ] [[package]] name = "datafusion-sql" -version = "50.3.0" +version = "52.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89abd9868770386fede29e5a4b14f49c0bf48d652c3b9d7a8a0332329b87d50b" +checksum = "be53e9eb55db0fbb8980bb6d87f2435b0524acf4c718ed54a57cabbb299b2ab3" dependencies = [ "arrow", "bigdecimal", + "chrono", "datafusion-common", "datafusion-expr", "indexmap 2.14.0", @@ -1762,6 +1890,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1801,6 +1940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -1826,11 +1966,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1846,12 +1996,6 @@ dependencies = [ "litrs", ] -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - [[package]] name = "downcast-rs" version = "2.0.2" @@ -1873,11 +2017,70 @@ dependencies = [ "cipher", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] [[package]] name = "encode_unicode" @@ -1912,9 +2115,9 @@ dependencies = [ [[package]] name = "ethnum" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" [[package]] name = "euclid" @@ -1964,6 +2167,22 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2008,6 +2227,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[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" @@ -2029,12 +2254,12 @@ dependencies = [ [[package]] name = "fsst" -version = "0.39.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2475ce218217196b161b025598f77e2b405d5e729f7c37bfff145f5df00a41" +checksum = "2195cc7f87e84bd695586137de99605e7e9579b26ec5e01b82960ddb4d0922f2" dependencies = [ "arrow-array", - "rand 0.9.2", + "rand 0.9.4", ] [[package]] @@ -2152,7 +2377,7 @@ dependencies = [ "log", "rustversion", "windows-link", - "windows-result 0.4.1", + "windows-result", ] [[package]] @@ -2163,6 +2388,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2211,11 +2437,22 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -2253,10 +2490,6 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] [[package]] name = "hashbrown" @@ -2266,7 +2499,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2274,12 +2507,17 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -2299,6 +2537,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "htmlescape" version = "0.3.1" @@ -2307,9 +2563,9 @@ checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -2358,9 +2614,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -2380,16 +2636,15 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -2401,7 +2656,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2439,7 +2694,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -2558,9 +2813,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -2584,7 +2839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -2634,34 +2889,12 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2670,9 +2903,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.12.1" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" dependencies = [ "either", ] @@ -2703,9 +2936,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.23" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "jiff-tzdb-platform", @@ -2713,14 +2946,14 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-link", ] [[package]] name = "jiff-static" -version = "0.2.23" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", @@ -2754,9 +2987,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -2777,8 +3010,8 @@ dependencies = [ "jiff", "nom 8.0.0", "num-traits", - "ordered-float", - "rand 0.9.2", + "ordered-float 5.3.0", + "rand 0.9.4", "serde", "serde_json", "zmij", @@ -2786,9 +3019,9 @@ dependencies = [ [[package]] name = "lance" -version = "0.39.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2f0ca022d0424d991933a62d2898864cf5621873962bd84e65e7d1f023f9c36" +checksum = "efe6c3ddd79cdfd2b7e1c23cafae52806906bc40fbd97de9e8cf2f8c7a75fc04" dependencies = [ "arrow", "arrow-arith", @@ -2805,6 +3038,7 @@ dependencies = [ "byteorder", "bytes", "chrono", + "crossbeam-skiplist", "dashmap", "datafusion", "datafusion-expr", @@ -2834,15 +3068,16 @@ dependencies = [ "pin-project", "prost", "prost-types", - "rand 0.9.2", + "rand 0.9.4", "roaring", "semver", "serde", "serde_json", - "snafu", - "tantivy 0.24.2", + "snafu 0.9.1", + "tantivy", "tokio", "tokio-stream", + "tokio-util", "tracing", "url", "uuid", @@ -2850,29 +3085,31 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "0.39.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7552f8d528775bf0ab21e1f75dcb70bdb2a828eeae58024a803b5a4655fd9a11" +checksum = "5d9f5d95bdda2a2b790f1fb8028b5b6dcf661abeb3133a8bca0f3d24b054af87" dependencies = [ "arrow-array", "arrow-buffer", "arrow-cast", "arrow-data", + "arrow-ord", "arrow-schema", "arrow-select", "bytes", + "futures", "getrandom 0.2.17", "half", "jsonb", "num-traits", - "rand 0.9.2", + "rand 0.9.4", ] [[package]] name = "lance-bitpacking" -version = "0.39.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2ea14583cc6fa0bb190bcc2d3bc364b0aa545b345702976025f810e4740e8ce" +checksum = "f827d6ab9f8f337a9509d5ad66a12f3314db8713868260521c344ef6135eb4e4" dependencies = [ "arrayref", "paste", @@ -2881,9 +3118,9 @@ dependencies = [ [[package]] name = "lance-core" -version = "0.39.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69c752dedd207384892006c40930f898d6634e05e3d489e89763abfe4b9307e7" +checksum = "0f1e25df6a79bf72ee6bcde0851f19b1cd36c5848c1b7db83340882d3c9fdecb" dependencies = [ "arrow-array", "arrow-buffer", @@ -2896,6 +3133,7 @@ dependencies = [ "datafusion-sql", "deepsize", "futures", + "itertools 0.13.0", "lance-arrow", "libc", "log", @@ -2905,10 +3143,10 @@ dependencies = [ "object_store", "pin-project", "prost", - "rand 0.9.2", + "rand 0.9.4", "roaring", "serde_json", - "snafu", + "snafu 0.9.1", "tempfile", "tokio", "tokio-stream", @@ -2919,9 +3157,9 @@ dependencies = [ [[package]] name = "lance-datafusion" -version = "0.39.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e1e98ca6e5cd337bdda2d9fb66063f295c0c2852d2bc6831366fea833ee608" +checksum = "93146de8ae720cb90edef81c2f2d0a1b065fc2f23ecff2419546f389b0fa70a4" dependencies = [ "arrow", "arrow-array", @@ -2943,16 +3181,17 @@ dependencies = [ "log", "pin-project", "prost", - "snafu", + "prost-build", + "snafu 0.9.1", "tokio", "tracing", ] [[package]] name = "lance-datagen" -version = "0.39.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "483c643fc2806ed1a2766edf4d180511bbd1d549bcc60373e33f4785c6185891" +checksum = "ccec8ce4d8e0a87a99c431dab2364398029f2ffb649c1a693c60c79e05ed30dd" dependencies = [ "arrow", "arrow-array", @@ -2962,16 +3201,17 @@ dependencies = [ "futures", "half", "hex", - "rand 0.9.2", + "rand 0.9.4", + "rand_distr 0.5.1", "rand_xoshiro", "random_word", ] [[package]] name = "lance-encoding" -version = "0.39.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a199d1fa3487529c5ffc433fbd1721231330b9350c2ff9b0c7b7dbdb98f0806a" +checksum = "5c1aec0bbbac6bce829bc10f1ba066258126100596c375fb71908ecf11c2c2a5" dependencies = [ "arrow-arith", "arrow-array", @@ -2997,8 +3237,8 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand 0.9.2", - "snafu", + "rand 0.9.4", + "snafu 0.9.1", "strum", "tokio", "tracing", @@ -3008,9 +3248,9 @@ dependencies = [ [[package]] name = "lance-file" -version = "0.39.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b57def2279465232cf5a8cd996300c632442e368745768bbed661c7f0a35334b" +checksum = "14a8c548804f5b17486dc2d3282356ed1957095a852780283bc401fdd69e9075" dependencies = [ "arrow-arith", "arrow-array", @@ -3035,16 +3275,16 @@ dependencies = [ "prost", "prost-build", "prost-types", - "snafu", + "snafu 0.9.1", "tokio", "tracing", ] [[package]] name = "lance-index" -version = "0.39.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75938c61e986aef8c615dc44c92e4c19e393160a59e2b57402ccfe08c5e63af" +checksum = "2da212f0090ea59f79ac3686660f596520c167fe1cb5f408900cf71d215f0e03" dependencies = [ "arrow", "arrow-arith", @@ -3058,6 +3298,7 @@ dependencies = [ "bitpacking", "bitvec", "bytes", + "chrono", "crossbeam-queue", "datafusion", "datafusion-common", @@ -3088,14 +3329,16 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand 0.9.2", + "rand 0.9.4", "rand_distr 0.5.1", + "rangemap", "rayon", "roaring", "serde", "serde_json", - "snafu", - "tantivy 0.24.2", + "smallvec", + "snafu 0.9.1", + "tantivy", "tempfile", "tokio", "tracing", @@ -3105,9 +3348,9 @@ dependencies = [ [[package]] name = "lance-io" -version = "0.39.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa6c3b5b28570d6c951206c5b043f1b35c936928af14fca6f2ac25b0097e4c32" +checksum = "41d958eb4b56f03bbe0f5f85eb2b4e9657882812297b6f711f201ffc995f259f" dependencies = [ "arrow", "arrow-arith", @@ -3124,6 +3367,7 @@ dependencies = [ "chrono", "deepsize", "futures", + "http", "lance-arrow", "lance-core", "lance-namespace", @@ -3132,10 +3376,10 @@ dependencies = [ "path_abs", "pin-project", "prost", - "rand 0.9.2", + "rand 0.9.4", "serde", - "shellexpand", - "snafu", + "snafu 0.9.1", + "tempfile", "tokio", "tracing", "url", @@ -3143,9 +3387,9 @@ dependencies = [ [[package]] name = "lance-linalg" -version = "0.39.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3cbc7e85a89ff9cb3a4627559dea3fd1c1fb16c0d8bc46ede75eefef51eec06" +checksum = "0285b70da35def7ed95e150fae1d5308089554e1290470403ed3c50cb235bc5e" dependencies = [ "arrow-array", "arrow-buffer", @@ -3156,48 +3400,57 @@ dependencies = [ "lance-arrow", "lance-core", "num-traits", - "rand 0.9.2", + "rand 0.9.4", ] [[package]] name = "lance-namespace" -version = "0.39.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897dd6726816515bb70a698ce7cda44670dca5761637696d7905b45f405a8cd9" +checksum = "5f78e2a828b654e062a495462c6e3eb4fcf0e7e907d761b8f217fc09ccd3ceac" dependencies = [ "arrow", "async-trait", "bytes", "lance-core", "lance-namespace-reqwest-client", - "snafu", + "serde", + "snafu 0.9.1", ] [[package]] name = "lance-namespace-impls" -version = "0.39.0" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2392314f3da38f00d166295e44244208a65ccfc256e274fa8631849fc3f4d94" dependencies = [ "arrow", "arrow-ipc", "arrow-schema", "async-trait", "bytes", + "chrono", + "futures", "lance", "lance-core", + "lance-index", "lance-io", "lance-namespace", + "lance-table", + "log", "object_store", - "reqwest", + "rand 0.9.4", "serde_json", - "snafu", + "snafu 0.9.1", + "tokio", "url", ] [[package]] name = "lance-namespace-reqwest-client" -version = "0.0.18" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ea349999bcda4eea53fc05d334b3775ec314761e6a706555c777d7a29b18d19" +checksum = "ee2e48de899e2931afb67fcddd0a08e439bf5d8b6ea2a2ed9cb8f4df669bd5cc" dependencies = [ "reqwest", "serde", @@ -3208,9 +3461,9 @@ dependencies = [ [[package]] name = "lance-table" -version = "0.39.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8facc13760ba034b6c38767b16adba85e44cbcbea8124dc0c63c43865c60630" +checksum = "3df9c4adca3eb2074b3850432a9fb34248a3d90c3d6427d158b13ff9355664ee" dependencies = [ "arrow", "arrow-array", @@ -3232,13 +3485,13 @@ dependencies = [ "prost", "prost-build", "prost-types", - "rand 0.9.2", + "rand 0.9.4", "rangemap", "roaring", "semver", "serde", "serde_json", - "snafu", + "snafu 0.9.1", "tokio", "tracing", "url", @@ -3247,22 +3500,22 @@ dependencies = [ [[package]] name = "lance-testing" -version = "0.39.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05052ef86188d6ae6339bdd9f2c5d77190e8ad1158f3dc8a42fa91bde9e5246" +checksum = "7ed7119bdd6983718387b4ac44af873a165262ca94f181b104cd6f97912eb3bf" dependencies = [ "arrow-array", "arrow-schema", "lance-arrow", "num-traits", - "rand 0.9.2", + "rand 0.9.4", ] [[package]] name = "lancedb" -version = "0.22.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1da241266792d8caa58005a3deb06ba1388a99350d89b5c904ef6f8de5d936f" +checksum = "ce0f4d7f739dc30608fe8b202cbb40986c2937e1a5a189f98fb06d7b8543156a" dependencies = [ "ahash", "arrow", @@ -3281,7 +3534,10 @@ dependencies = [ "datafusion-common", "datafusion-execution", "datafusion-expr", + "datafusion-functions", + "datafusion-physical-expr", "datafusion-physical-plan", + "datafusion-sql", "futures", "half", "lance", @@ -3304,13 +3560,13 @@ dependencies = [ "num-traits", "object_store", "pin-project", - "rand 0.9.2", + "rand 0.9.4", "regex", "semver", "serde", "serde_json", "serde_with", - "snafu", + "snafu 0.8.9", "tempfile", "tokio", "url", @@ -3322,6 +3578,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -3394,9 +3653,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -3406,9 +3665,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -3448,9 +3707,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "loom" @@ -3484,7 +3743,7 @@ dependencies = [ "md-5", "nom 8.0.0", "nom_locate", - "rand 0.9.2", + "rand 0.9.4", "rangemap", "sha2", "stringprep", @@ -3532,6 +3791,12 @@ name = "lz4_flex" version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" + +[[package]] +name = "lz4_flex" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90071f8077f8e40adfc4b7fe9cd495ce316263f19e75c2211eeff3fdf475a3d9" dependencies = [ "twox-hash", ] @@ -3574,16 +3839,6 @@ dependencies = [ "digest", ] -[[package]] -name = "measure_time" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbefd235b0aadd181626f281e1d684e116972988c14c264e42069d5e8a5775cc" -dependencies = [ - "instant", - "log", -] - [[package]] name = "measure_time" version = "0.9.0" @@ -3595,9 +3850,17 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memex-contracts" +version = "0.6.5" +dependencies = [ + "serde", + "serde_json", +] [[package]] name = "memmap2" @@ -3642,9 +3905,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -3678,6 +3941,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "multimap" version = "0.10.1" @@ -3754,27 +4034,29 @@ dependencies = [ ] [[package]] -name = "num" -version = "0.4.3" +name = "num-bigint" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ - "num-bigint", - "num-complex", "num-integer", - "num-iter", - "num-rational", "num-traits", ] [[package]] -name = "num-bigint" -version = "0.4.6" +name = "num-bigint-dig" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ + "lazy_static", + "libm", "num-integer", + "num-iter", "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", ] [[package]] @@ -3788,9 +4070,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -3812,17 +4094,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -3849,6 +4120,83 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + [[package]] name = "object_store" version = "0.12.5" @@ -3891,6 +4239,37 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http", + "itertools 0.10.5", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.6", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -3905,20 +4284,20 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "ordered-float" -version = "5.3.0" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" dependencies = [ "num-traits", ] [[package]] -name = "ownedbytes" -version = "0.7.0" +name = "ordered-float" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3a059efb063b8f425b948e042e6b9bd85edfe60e913630ed727b23e2dfcc558" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" dependencies = [ - "stable_deref_trait", + "num-traits", ] [[package]] @@ -3930,6 +4309,30 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -3959,6 +4362,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -3994,6 +4408,15 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4006,16 +4429,6 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df202b0b0f5b8e389955afd5f27b007b00fb948162953f1db9c70d2c7e3157d7" -[[package]] -name = "petgraph" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" -dependencies = [ - "fixedbitset", - "indexmap 2.14.0", -] - [[package]] name = "petgraph" version = "0.8.3" @@ -4048,18 +4461,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -4072,11 +4485,32 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "pom" @@ -4092,9 +4526,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] @@ -4139,6 +4573,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -4150,9 +4593,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.13.5" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", "prost-derive", @@ -4160,16 +4603,15 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.13.5" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", "itertools 0.14.0", "log", "multimap", - "once_cell", - "petgraph 0.7.1", + "petgraph", "prettyplease", "prost", "prost-types", @@ -4180,9 +4622,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.13.5" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", "itertools 0.14.0", @@ -4193,9 +4635,9 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.13.5" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ "prost", ] @@ -4275,7 +4717,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.2", + "rustc-hash", "rustls", "socket2", "thiserror 2.0.18", @@ -4293,9 +4735,9 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", - "rustc-hash 2.1.2", + "rustc-hash", "rustls", "rustls-pki-types", "slab", @@ -4348,9 +4790,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4359,9 +4801,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -4412,7 +4854,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ "num-traits", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -4422,7 +4864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.2", + "rand 0.9.4", ] [[package]] @@ -4443,7 +4885,7 @@ dependencies = [ "ahash", "brotli", "paste", - "rand 0.9.2", + "rand 0.9.4", "unicase", ] @@ -4482,9 +4924,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -4575,7 +5017,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -4614,6 +5056,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -4630,19 +5082,41 @@ dependencies = [ [[package]] name = "roaring" -version = "0.10.12" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e8d2cfa184d94d0726d650a9f4a1be7f9b76ac9fdb954219878dc00c1c1e7b" +checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" dependencies = [ "bytemuck", "byteorder", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rust-memex" -version = "0.6.0" +version = "0.6.5" dependencies = [ + "aicx-parser", "anyhow", + "argon2", "arrow-array", "arrow-schema", "async-stream", @@ -4654,6 +5128,8 @@ dependencies = [ "glob", "indicatif", "lancedb", + "memex-contracts", + "openidconnect", "pdf-extract", "protoc-bin-vendored", "ratatui", @@ -4663,12 +5139,14 @@ dependencies = [ "serde_json", "sha2", "shellexpand", + "subtle", "sysinfo", - "tantivy 0.22.1", + "tantivy", "tempfile", "tokio", "tokio-stream", "toml", + "tower", "tower-http", "tracing", "tracing-subscriber", @@ -4686,12 +5164,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - [[package]] name = "rustc-hash" version = "2.1.2" @@ -4735,9 +5207,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "once_cell", "ring", @@ -4761,9 +5233,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -4771,9 +5243,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.11" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -4846,6 +5318,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4891,6 +5377,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float 2.10.1", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4913,9 +5409,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -4935,6 +5431,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -4948,11 +5453,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -4969,11 +5474,12 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ - "base64", + "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", @@ -4988,9 +5494,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ "darling", "proc-macro2", @@ -5029,9 +5535,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -5064,6 +5570,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -5078,18 +5594,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - -[[package]] -name = "sketches-ddsketch" -version = "0.2.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" -dependencies = [ - "serde", -] +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "sketches-ddsketch" @@ -5118,7 +5625,16 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" dependencies = [ - "snafu-derive", + "snafu-derive 0.8.9", +] + +[[package]] +name = "snafu" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1a012328be2e3f5d5f6f3218147ca02588cea4cb865e876849ab6debcf36522" +dependencies = [ + "snafu-derive 0.9.1", ] [[package]] @@ -5133,21 +5649,49 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "snafu-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlparser" -version = "0.58.0" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec4b661c54b1e4b603b37873a18c59920e4c51ea8ea2cf527d925424dbd4437c" +checksum = "4591acadbcf52f0af60eafbb2c003232b2b4cd8de5f0e9437cb8b1b59046cc0f" dependencies = [ "log", "sqlparser_derive", @@ -5277,15 +5821,16 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.33.1" +version = "0.39.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96" dependencies = [ - "core-foundation-sys", "libc", "memchr", "ntapi", - "rayon", + "objc2-core-foundation", + "objc2-io-kit", + "objc2-open-directory", "windows", ] @@ -5295,57 +5840,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" -[[package]] -name = "tantivy" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96599ea6fccd844fc833fed21d2eecac2e6a7c1afd9e044057391d78b1feb141" -dependencies = [ - "aho-corasick", - "arc-swap", - "base64", - "bitpacking", - "byteorder", - "census", - "crc32fast", - "crossbeam-channel", - "downcast-rs 1.2.1", - "fastdivide", - "fnv", - "fs4", - "htmlescape", - "itertools 0.12.1", - "levenshtein_automata", - "log", - "lru", - "lz4_flex", - "measure_time 0.8.3", - "memmap2", - "num_cpus", - "once_cell", - "oneshot", - "rayon", - "regex", - "rust-stemmers", - "rustc-hash 1.1.0", - "serde", - "serde_json", - "sketches-ddsketch 0.2.2", - "smallvec", - "tantivy-bitpacker 0.6.0", - "tantivy-columnar 0.3.0", - "tantivy-common 0.7.0", - "tantivy-fst", - "tantivy-query-grammar 0.22.0", - "tantivy-stacker 0.3.0", - "tantivy-tokenizer-api 0.3.0", - "tempfile", - "thiserror 1.0.69", - "time", - "uuid", - "winapi", -] - [[package]] name = "tantivy" version = "0.24.2" @@ -5354,14 +5848,14 @@ checksum = "64a966cb0e76e311f09cf18507c9af192f15d34886ee43d7ba7c7e3803660c43" dependencies = [ "aho-corasick", "arc-swap", - "base64", + "base64 0.22.1", "bitpacking", "bon", "byteorder", "census", "crc32fast", "crossbeam-channel", - "downcast-rs 2.0.2", + "downcast-rs", "fastdivide", "fnv", "fs4", @@ -5371,26 +5865,26 @@ dependencies = [ "levenshtein_automata", "log", "lru", - "lz4_flex", - "measure_time 0.9.0", + "lz4_flex 0.11.6", + "measure_time", "memmap2", "once_cell", "oneshot", "rayon", "regex", "rust-stemmers", - "rustc-hash 2.1.2", + "rustc-hash", "serde", "serde_json", - "sketches-ddsketch 0.3.1", + "sketches-ddsketch", "smallvec", - "tantivy-bitpacker 0.8.0", - "tantivy-columnar 0.5.0", - "tantivy-common 0.9.0", + "tantivy-bitpacker", + "tantivy-columnar", + "tantivy-common", "tantivy-fst", - "tantivy-query-grammar 0.24.0", - "tantivy-stacker 0.5.0", - "tantivy-tokenizer-api 0.5.0", + "tantivy-query-grammar", + "tantivy-stacker", + "tantivy-tokenizer-api", "tempfile", "thiserror 2.0.18", "time", @@ -5398,15 +5892,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "tantivy-bitpacker" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "284899c2325d6832203ac6ff5891b297fc5239c3dc754c5bc1977855b23c10df" -dependencies = [ - "bitpacking", -] - [[package]] name = "tantivy-bitpacker" version = "0.8.0" @@ -5416,49 +5901,20 @@ dependencies = [ "bitpacking", ] -[[package]] -name = "tantivy-columnar" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12722224ffbe346c7fec3275c699e508fd0d4710e629e933d5736ec524a1f44e" -dependencies = [ - "downcast-rs 1.2.1", - "fastdivide", - "itertools 0.12.1", - "serde", - "tantivy-bitpacker 0.6.0", - "tantivy-common 0.7.0", - "tantivy-sstable 0.3.0", - "tantivy-stacker 0.3.0", -] - [[package]] name = "tantivy-columnar" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6300428e0c104c4f7db6f95b466a6f5c1b9aece094ec57cdd365337908dc7344" dependencies = [ - "downcast-rs 2.0.2", + "downcast-rs", "fastdivide", "itertools 0.14.0", "serde", - "tantivy-bitpacker 0.8.0", - "tantivy-common 0.9.0", - "tantivy-sstable 0.5.0", - "tantivy-stacker 0.5.0", -] - -[[package]] -name = "tantivy-common" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8019e3cabcfd20a1380b491e13ff42f57bb38bf97c3d5fa5c07e50816e0621f4" -dependencies = [ - "async-trait", - "byteorder", - "ownedbytes 0.7.0", - "serde", - "time", + "tantivy-bitpacker", + "tantivy-common", + "tantivy-sstable", + "tantivy-stacker", ] [[package]] @@ -5469,7 +5925,7 @@ checksum = "e91b6ea6090ce03dc72c27d0619e77185d26cc3b20775966c346c6d4f7e99d7f" dependencies = [ "async-trait", "byteorder", - "ownedbytes 0.9.0", + "ownedbytes", "serde", "time", ] @@ -5485,15 +5941,6 @@ dependencies = [ "utf8-ranges", ] -[[package]] -name = "tantivy-query-grammar" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "847434d4af57b32e309f4ab1b4f1707a6c566656264caa427ff4285c4d9d0b82" -dependencies = [ - "nom 7.1.3", -] - [[package]] name = "tantivy-query-grammar" version = "0.24.0" @@ -5505,18 +5952,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "tantivy-sstable" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c69578242e8e9fc989119f522ba5b49a38ac20f576fc778035b96cc94f41f98e" -dependencies = [ - "tantivy-bitpacker 0.6.0", - "tantivy-common 0.7.0", - "tantivy-fst", - "zstd", -] - [[package]] name = "tantivy-sstable" version = "0.5.0" @@ -5525,23 +5960,12 @@ checksum = "709f22c08a4c90e1b36711c1c6cad5ae21b20b093e535b69b18783dd2cb99416" dependencies = [ "futures-util", "itertools 0.14.0", - "tantivy-bitpacker 0.8.0", - "tantivy-common 0.9.0", + "tantivy-bitpacker", + "tantivy-common", "tantivy-fst", "zstd", ] -[[package]] -name = "tantivy-stacker" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56d6ff5591fc332739b3ce7035b57995a3ce29a93ffd6012660e0949c956ea8" -dependencies = [ - "murmurhash32", - "rand_distr 0.4.3", - "tantivy-common 0.7.0", -] - [[package]] name = "tantivy-stacker" version = "0.5.0" @@ -5550,16 +5974,7 @@ checksum = "2bcdebb267671311d1e8891fd9d1301803fdb8ad21ba22e0a30d0cab49ba59c1" dependencies = [ "murmurhash32", "rand_distr 0.4.3", - "tantivy-common 0.9.0", -] - -[[package]] -name = "tantivy-tokenizer-api" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0dcade25819a89cfe6f17d932c9cedff11989936bf6dd4f336d50392053b04" -dependencies = [ - "serde", + "tantivy-common", ] [[package]] @@ -5715,9 +6130,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -5777,44 +6192,42 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "serde", + "indexmap 2.14.0", + "serde_core", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.6.11" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ - "serde", + "serde_core", ] [[package]] -name = "toml_edit" -version = "0.22.27" +name = "toml_parser" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "indexmap 2.14.0", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_writer" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -5834,9 +6247,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", "bitflags", @@ -5846,13 +6259,13 @@ dependencies = [ "http", "http-body", "http-body-util", - "iri-string", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -5947,7 +6360,7 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" dependencies = [ - "rand 0.9.2", + "rand 0.9.4", ] [[package]] @@ -5961,9 +6374,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" @@ -6049,6 +6462,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -6071,9 +6485,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -6120,11 +6534,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -6133,14 +6547,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -6151,9 +6565,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -6161,9 +6575,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6171,9 +6585,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -6184,9 +6598,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -6240,9 +6654,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -6297,24 +6711,23 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.57.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-core 0.57.0", - "windows-targets 0.52.6", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] -name = "windows-core" -version = "0.57.0" +name = "windows-collections" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core", ] [[package]] @@ -6323,22 +6736,22 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link", - "windows-result 0.4.1", + "windows-result", "windows-strings", ] [[package]] -name = "windows-implement" -version = "0.57.0" +name = "windows-future" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "windows-core", + "windows-link", + "windows-threading", ] [[package]] @@ -6352,17 +6765,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "windows-interface" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -6381,12 +6783,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-result" -version = "0.1.2" +name = "windows-numerics" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-targets 0.52.6", + "windows-core", + "windows-link", ] [[package]] @@ -6476,6 +6879,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6574,12 +6986,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" [[package]] name = "wit-bindgen" @@ -6590,6 +6999,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -6715,18 +7130,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", @@ -6735,9 +7150,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -6826,3 +7241,7 @@ dependencies = [ "cc", "pkg-config", ] + +[[patch.unused]] +name = "lance-namespace-impls" +version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index f0c5994..6dee851 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,10 @@ -[package] -name = "rust-memex" -version = "0.6.0" +[workspace] +resolver = "2" +members = ["crates/rust-memex", "crates/memex-contracts"] +default-members = ["crates/rust-memex"] + +[workspace.package] +version = "0.6.5" edition = "2024" rust-version = "1.88" description = "Operator CLI + MCP server: canonical corpus second: semantic index second to aicx" @@ -8,92 +12,47 @@ authors = ["Maciej Gad ", "Monika Szymanska + # Security Policy ## Supported Versions @@ -13,7 +13,7 @@ Please use GitHub Security Advisories for responsible disclosure: https://github.com/Loctree/rust-memex/security/advisories/new -If GitHub Advisories are unavailable, email security@loct.io with a +If GitHub Advisories are unavailable, email security@vetcoders.io with a summary, affected version, reproduction steps, and expected impact. Please do not report suspected vulnerabilities in public GitHub issues. diff --git a/crates/memex-contracts/Cargo.toml b/crates/memex-contracts/Cargo.toml new file mode 100644 index 0000000..829de93 --- /dev/null +++ b/crates/memex-contracts/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "memex-contracts" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Shared serde contracts for rust-memex transports and future frontends" +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true diff --git a/crates/memex-contracts/src/audit.rs b/crates/memex-contracts/src/audit.rs new file mode 100644 index 0000000..ecb3e19 --- /dev/null +++ b/crates/memex-contracts/src/audit.rs @@ -0,0 +1,100 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChunkQuality { + pub avg_chunk_length: usize, + pub sentence_integrity: f32, + pub word_integrity: f32, + pub chunk_quality: f32, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum QualityTier { + Empty, + Purge, + Warn, + Good, + Excellent, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AuditRecommendation { + Empty, + Purge, + Warn, + Good, + Excellent, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AuditResult { + pub namespace: String, + pub document_count: usize, + pub avg_chunk_length: usize, + pub sentence_integrity: f32, + pub word_integrity: f32, + pub chunk_quality: f32, + pub overall_score: f32, + pub recommendation: AuditRecommendation, + pub passes_threshold: bool, +} + +impl AuditResult { + pub fn chunk_quality_metrics(&self) -> ChunkQuality { + ChunkQuality { + avg_chunk_length: self.avg_chunk_length, + sentence_integrity: self.sentence_integrity, + word_integrity: self.word_integrity, + chunk_quality: self.chunk_quality, + } + } + + pub fn quality_tier(&self) -> QualityTier { + match self.recommendation { + AuditRecommendation::Empty => QualityTier::Empty, + AuditRecommendation::Purge => QualityTier::Purge, + AuditRecommendation::Warn => QualityTier::Warn, + AuditRecommendation::Good => QualityTier::Good, + AuditRecommendation::Excellent => QualityTier::Excellent, + } + } +} + +impl AuditRecommendation { + pub const fn as_str(self) -> &'static str { + match self { + AuditRecommendation::Empty => "EMPTY", + AuditRecommendation::Purge => "PURGE", + AuditRecommendation::Warn => "WARN", + AuditRecommendation::Good => "GOOD", + AuditRecommendation::Excellent => "EXCELLENT", + } + } +} + +impl fmt::Display for AuditRecommendation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl QualityTier { + pub const fn as_str(self) -> &'static str { + match self { + QualityTier::Empty => "EMPTY", + QualityTier::Purge => "PURGE", + QualityTier::Warn => "WARN", + QualityTier::Good => "GOOD", + QualityTier::Excellent => "EXCELLENT", + } + } +} + +impl fmt::Display for QualityTier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/crates/memex-contracts/src/lib.rs b/crates/memex-contracts/src/lib.rs new file mode 100644 index 0000000..19990bb --- /dev/null +++ b/crates/memex-contracts/src/lib.rs @@ -0,0 +1,4 @@ +pub mod audit; +pub mod progress; +pub mod stats; +pub mod timeline; diff --git a/crates/memex-contracts/src/progress.rs b/crates/memex-contracts/src/progress.rs new file mode 100644 index 0000000..3b25f0d --- /dev/null +++ b/crates/memex-contracts/src/progress.rs @@ -0,0 +1,71 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SseEvent { + pub event: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub data: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ReindexProgress { + pub namespace: String, + pub total_files: usize, + pub processed_files: usize, + pub indexed_files: usize, + pub skipped_files: usize, + pub failed_files: usize, + pub total_chunks: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct MergeProgress { + pub total_docs: usize, + pub docs_copied: usize, + pub docs_skipped: usize, + pub namespaces: Vec, + pub sources_processed: usize, + pub errors: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ReprocessProgress { + pub source_label: String, + pub processed_documents: usize, + pub indexed_documents: usize, + pub skipped_documents: usize, + pub failed_documents: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct AuditProgress { + pub processed_namespaces: usize, + pub total_namespaces: usize, + pub current_namespace: Option, + pub threshold: u8, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct RepairResult { + pub recovery_dir: String, + pub pending_batches: usize, + pub repaired_documents: usize, + pub skipped_documents: usize, + pub batches_repaired: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct CompactProgress { + pub phase: String, + pub status: String, + pub description: Option, + pub files_removed: Option, + pub files_added: Option, + pub fragments_removed: Option, + pub fragments_added: Option, + pub old_versions: Option, + pub bytes_removed: Option, + pub elapsed_ms: Option, +} diff --git a/crates/memex-contracts/src/stats.rs b/crates/memex-contracts/src/stats.rs new file mode 100644 index 0000000..4b05364 --- /dev/null +++ b/crates/memex-contracts/src/stats.rs @@ -0,0 +1,30 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct NamespaceStats { + pub name: String, + pub total_chunks: usize, + pub layer_counts: HashMap, + pub top_keywords: Vec<(String, usize)>, + pub has_timestamps: bool, + pub earliest_indexed: Option, + pub latest_indexed: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct DatabaseStats { + pub row_count: usize, + pub version_count: usize, + pub table_name: String, + pub db_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct StorageMetrics { + pub total_namespaces: usize, + pub total_documents: usize, + pub bytes_used: Option, + pub bytes_reclaimed: Option, +} diff --git a/crates/memex-contracts/src/timeline.rs b/crates/memex-contracts/src/timeline.rs new file mode 100644 index 0000000..d4d368e --- /dev/null +++ b/crates/memex-contracts/src/timeline.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct TimelineEntry { + pub date: String, + pub namespace: String, + pub source: Option, + pub chunk_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct TimeRange { + pub start: Option, + pub end: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct TimelineFilter { + pub namespace: Option, + pub since: Option, + pub gaps_only: bool, +} diff --git a/crates/rust-memex/Cargo.toml b/crates/rust-memex/Cargo.toml new file mode 100644 index 0000000..b0bb791 --- /dev/null +++ b/crates/rust-memex/Cargo.toml @@ -0,0 +1,91 @@ +[package] +name = "rust-memex" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +documentation = "https://docs.rs/rust-memex" +readme = "../../README.md" + +[features] +default = ["cli", "provider-cascade"] + +# CLI feature: enables the binary, TUI wizard, and progress bars +cli = ["clap", "indicatif", "ratatui", "crossterm", "sysinfo"] + +# Provider cascade: current Ollama/OpenAI-compatible embedding approach +provider-cascade = [] + +# E2E integration tests against a real embedding provider cascade. +# Tests load the operator's canonical `~/.rmcp-servers/rust-memex/config.toml` +# (or RUST_MEMEX_CONFIG override) and exercise full lifecycle: index → embed → +# store → search → delete → restore. Run with: `make test-e2e` or +# `cargo test --features e2e-ollama --workspace --no-fail-fast`. +e2e-ollama = [] + +# Embedded Candle embeddings (placeholder for future) +# embedded-candle = ["candle-core", "candle-nn", "candle-transformers", "tokenizers", "hf-hub"] +# +# NOTE: The historical `ollama-outer` feature flag has been removed. Outer-layer +# synthesis via Ollama is now always compiled in (reqwest is an unconditional +# dep) and gated at runtime by `OnionSliceConfig::outer_synthesis` +# (`OuterSynthesis::Keyword` vs `OuterSynthesis::Llm`). See spec P3. + +[lib] +name = "rust_memex" +path = "src/lib.rs" + +[[bin]] +name = "rust-memex" +path = "src/bin/rust_memex.rs" +required-features = ["cli"] + +[dependencies] +aicx-parser.workspace = true +anyhow.workspace = true +argon2.workspace = true +arrow-array.workspace = true +arrow-schema.workspace = true +async-stream.workspace = true +axum.workspace = true +chrono.workspace = true +clap = { workspace = true, optional = true } +crossterm = { workspace = true, optional = true } +futures.workspace = true +glob.workspace = true +indicatif = { workspace = true, optional = true } +lancedb.workspace = true +memex-contracts = { path = "../memex-contracts", version = "0.6.5" } +openidconnect.workspace = true +pdf-extract.workspace = true +ratatui = { workspace = true, optional = true } +regex.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +shellexpand.workspace = true +subtle.workspace = true +sysinfo = { workspace = true, optional = true } +tantivy.workspace = true +tokio.workspace = true +tokio-stream.workspace = true +toml.workspace = true +tower-http.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +uuid.workspace = true +walkdir.workspace = true + +[build-dependencies] +protoc-bin-vendored.workspace = true + +[dev-dependencies] +tempfile.workspace = true +tower.workspace = true diff --git a/crates/rust-memex/src/auth/mod.rs b/crates/rust-memex/src/auth/mod.rs new file mode 100644 index 0000000..91e5ff6 --- /dev/null +++ b/crates/rust-memex/src/auth/mod.rs @@ -0,0 +1,1013 @@ +//! Multi-token auth with per-token scopes and namespace ACL. +//! +//! Replaces the single global bearer token with a flexible token store. +//! Each token is hashed with argon2id at rest. Plaintext is shown ONCE +//! on creation and never stored. +//! +//! Vibecrafted with AI Agents by Loctree (c)2024-2026 The LibraxisAI Team + +use std::fmt; +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; + +use anyhow::{Result, anyhow}; +use argon2::Argon2; +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +// ============================================================================ +// Scope +// ============================================================================ + +/// Permission scope for a token. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Scope { + Read, + Write, + Admin, +} + +impl fmt::Display for Scope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Scope::Read => write!(f, "read"), + Scope::Write => write!(f, "write"), + Scope::Admin => write!(f, "admin"), + } + } +} + +impl FromStr for Scope { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "read" => Ok(Scope::Read), + "write" => Ok(Scope::Write), + "admin" => Ok(Scope::Admin), + other => Err(anyhow!( + "Unknown scope '{}'. Use: read, write, admin", + other + )), + } + } +} + +// ============================================================================ +// TokenEntry +// ============================================================================ + +/// A single token entry persisted in tokens.json (v2 schema). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenEntry { + /// Human-readable identifier (e.g., "monika-iphone") + pub id: String, + /// Argon2id hash of the token. Plaintext never stored. + pub token_hash: String, + /// Permission scopes granted to this token. + pub scopes: Vec, + /// Namespace ACL. `["*"]` means all namespaces. + pub namespaces: Vec, + /// Optional expiry timestamp. `None` = never expires. + pub expires_at: Option>, + /// Human-readable description. + pub description: String, + /// When this token was created. + pub created_at: DateTime, +} + +impl TokenEntry { + /// Check if the token has expired. + pub fn is_expired(&self) -> bool { + if let Some(exp) = self.expires_at { + Utc::now() > exp + } else { + false + } + } + + /// Check if the token grants access to a given namespace. + pub fn has_namespace_access(&self, namespace: &str) -> bool { + self.namespaces + .iter() + .any(|ns| ns == "*" || ns == namespace) + } + + /// Check if the token has a given scope. + pub fn has_scope(&self, scope: &Scope) -> bool { + // Admin implies all scopes + self.scopes.contains(&Scope::Admin) || self.scopes.contains(scope) + } +} + +// ============================================================================ +// TokenStoreV2 +// ============================================================================ + +/// Version 2 token store schema, persisted as JSON. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenStoreV2 { + pub version: u32, + pub tokens: Vec, +} + +impl Default for TokenStoreV2 { + fn default() -> Self { + Self { + version: 2, + tokens: Vec::new(), + } + } +} + +/// Version 1 schema (legacy) for migration. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TokenEntryV1 { + namespace: String, + token: String, + created_at: u64, + description: Option, +} + +/// Persistent token store backed by `tokens.json`. +#[derive(Debug)] +pub struct TokenStoreFile { + store: Arc>, + store_path: String, +} + +impl TokenStoreFile { + /// Create a new token store at the given path. + pub fn new(store_path: String) -> Self { + Self { + store: Arc::new(RwLock::new(TokenStoreV2::default())), + store_path, + } + } + + /// Expand and return the canonical file path. + fn expanded_path(&self) -> String { + shellexpand::tilde(&self.store_path).to_string() + } + + /// Load tokens from disk. Handles v1 -> v2 migration. + pub async fn load(&self) -> Result<()> { + let expanded = self.expanded_path(); + let path = Path::new(&expanded); + + if !path.exists() { + debug!("No token store at {}, starting fresh", expanded); + return Ok(()); + } + + let contents = tokio::fs::read_to_string(path).await?; + + // Try v2 first + if let Ok(v2) = serde_json::from_str::(&contents) + && v2.version == 2 + { + let count = v2.tokens.len(); + let mut store = self.store.write().await; + *store = v2; + info!("Loaded {} tokens from v2 store at {}", count, expanded); + return Ok(()); + } + + // Try v1 (legacy: HashMap) + if let Ok(v1_map) = + serde_json::from_str::>(&contents) + { + info!( + "Detected v1 token store with {} entries, migrating to v2", + v1_map.len() + ); + + // Back up v1 + let backup_path = format!("{}.v1.bak", expanded); + tokio::fs::copy(&expanded, &backup_path).await?; + info!("Backed up v1 store to {}", backup_path); + + // Migrate each v1 token + let argon2 = Argon2::default(); + let mut migrated = Vec::new(); + for (ns, entry) in &v1_map { + let salt = SaltString::generate(&mut OsRng); + let hash = argon2 + .hash_password(entry.token.as_bytes(), &salt) + .map_err(|e| anyhow!("Failed to hash v1 token for '{}': {}", ns, e))? + .to_string(); + + migrated.push(TokenEntry { + id: format!("migrated-{}", ns), + token_hash: hash, + scopes: vec![Scope::Read, Scope::Write, Scope::Admin], + namespaces: vec![ns.clone()], + expires_at: None, + description: entry + .description + .clone() + .unwrap_or_else(|| format!("Migrated from v1 for namespace '{}'", ns)), + created_at: DateTime::from_timestamp(entry.created_at as i64, 0) + .unwrap_or_else(Utc::now), + }); + } + + let v2 = TokenStoreV2 { + version: 2, + tokens: migrated, + }; + let mut store = self.store.write().await; + *store = v2; + drop(store); + + self.save().await?; + warn!( + "Migrated v1 token store to v2. Old store backed up to {}", + backup_path + ); + return Ok(()); + } + + Err(anyhow!( + "Cannot parse token store at {}. Expected v2 or v1 format.", + expanded + )) + } + + /// Save current store to disk. + pub async fn save(&self) -> Result<()> { + let expanded = self.expanded_path(); + let path = Path::new(&expanded); + + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + let store = self.store.read().await; + let contents = serde_json::to_string_pretty(&*store)?; + tokio::fs::write(path, contents).await?; + debug!("Saved {} tokens to {}", store.tokens.len(), expanded); + Ok(()) + } + + /// Create a new token, hash it, store it, and return the plaintext. + pub async fn create_token( + &self, + id: String, + scopes: Vec, + namespaces: Vec, + expires_at: Option>, + description: String, + ) -> Result { + // Check for duplicate id + { + let store = self.store.read().await; + if store.tokens.iter().any(|t| t.id == id) { + return Err(anyhow!( + "Token with id '{}' already exists. Use 'auth revoke' first or pick a different id.", + id + )); + } + } + + // Generate plaintext token + let plaintext = format!("memex_{}", Uuid::new_v4().to_string().replace('-', "")); + + // Hash it + let argon2 = Argon2::default(); + let salt = SaltString::generate(&mut OsRng); + let hash = argon2 + .hash_password(plaintext.as_bytes(), &salt) + .map_err(|e| anyhow!("Failed to hash token: {}", e))? + .to_string(); + + let entry = TokenEntry { + id: id.clone(), + token_hash: hash, + scopes, + namespaces, + expires_at, + description, + created_at: Utc::now(), + }; + + { + let mut store = self.store.write().await; + store.tokens.push(entry); + } + + self.save().await?; + info!("Created token '{}'", id); + Ok(plaintext) + } + + /// List all token entries (no plaintext exposed). + pub async fn list_tokens(&self) -> Vec { + let store = self.store.read().await; + store.tokens.clone() + } + + /// Revoke (remove) a token by id. + pub async fn revoke_token(&self, id: &str) -> Result { + let removed = { + let mut store = self.store.write().await; + let before = store.tokens.len(); + store.tokens.retain(|t| t.id != id); + store.tokens.len() < before + }; + + if removed { + self.save().await?; + info!("Revoked token '{}'", id); + } + Ok(removed) + } + + /// Rotate a token: revoke old, create new with same metadata. + pub async fn rotate_token(&self, id: &str) -> Result { + let old_entry = { + let store = self.store.read().await; + store + .tokens + .iter() + .find(|t| t.id == id) + .cloned() + .ok_or_else(|| anyhow!("Token '{}' not found", id))? + }; + + // Remove old + { + let mut store = self.store.write().await; + store.tokens.retain(|t| t.id != id); + } + + // Create new with same metadata + self.create_token( + old_entry.id, + old_entry.scopes, + old_entry.namespaces, + old_entry.expires_at, + old_entry.description, + ) + .await + } + + /// Look up a token by verifying a plaintext against all stored hashes. + /// Returns the matching entry if found and valid. + pub async fn lookup_by_plaintext(&self, plaintext: &str) -> Option { + let store = self.store.read().await; + let argon2 = Argon2::default(); + + for entry in &store.tokens { + if let Ok(parsed_hash) = PasswordHash::new(&entry.token_hash) + && argon2 + .verify_password(plaintext.as_bytes(), &parsed_hash) + .is_ok() + { + return Some(entry.clone()); + } + } + None + } +} + +// ============================================================================ +// AuthManager +// ============================================================================ + +/// Unified auth manager replacing the legacy `NamespaceAccessManager`. +/// +/// Handles: +/// - Token lookup by hash (argon2id verification) +/// - Scope enforcement (read/write/admin) +/// - Namespace ACL checks +/// - Expiry checks +/// - Legacy `--auth-token` compatibility (mapped to wildcard token) +#[derive(Debug)] +pub struct AuthManager { + token_store: TokenStoreFile, + /// Legacy fallback: if set, a single token that grants wildcard access. + legacy_token: Option, +} + +/// Result of authenticating a request. +#[derive(Debug, Clone)] +pub struct AuthResult { + /// The token entry that authenticated the request. + pub token: TokenEntry, +} + +/// Reason an auth check was denied. +#[derive(Debug, Clone)] +pub enum AuthDenial { + /// No bearer token provided. + MissingToken, + /// Token provided but not recognized. + InvalidToken, + /// Token is expired. + Expired { id: String }, + /// Token lacks the required scope. + InsufficientScope { + id: String, + required: Scope, + granted: Vec, + }, + /// Token lacks access to the requested namespace. + NamespaceDenied { + id: String, + requested: String, + allowed: Vec, + }, +} + +impl fmt::Display for AuthDenial { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AuthDenial::MissingToken => write!(f, "Authorization header missing or malformed"), + AuthDenial::InvalidToken => write!(f, "Invalid or unrecognized token"), + AuthDenial::Expired { id } => write!(f, "Token '{}' has expired", id), + AuthDenial::InsufficientScope { + id, + required, + granted, + } => { + let granted_str: Vec = granted.iter().map(|s| s.to_string()).collect(); + write!( + f, + "Token '{}' lacks scope '{}' (has: [{}])", + id, + required, + granted_str.join(", ") + ) + } + AuthDenial::NamespaceDenied { + id, + requested, + allowed, + } => write!( + f, + "Token '{}' cannot access namespace '{}' (allowed: [{}])", + id, + requested, + allowed.join(", ") + ), + } + } +} + +impl AuthManager { + /// Create a new AuthManager with the given store path and optional legacy token. + pub fn new(store_path: String, legacy_token: Option) -> Self { + Self { + token_store: TokenStoreFile::new(store_path), + legacy_token, + } + } + + /// Initialize: load tokens from disk, warn about legacy token usage. + pub async fn init(&self) -> Result<()> { + self.token_store.load().await?; + + if self.legacy_token.is_some() { + warn!( + "DEPRECATED: --auth-token flag used. This maps to a single wildcard token. \ + Migrate to 'rust-memex auth create' for per-token scopes and namespace ACL." + ); + } + Ok(()) + } + + /// Authenticate a bearer token. Returns the matched entry or denial reason. + pub async fn authenticate(&self, bearer_token: &str) -> Result { + // Check legacy token first + if let Some(ref legacy) = self.legacy_token + && bearer_token == legacy + { + return Ok(AuthResult { + token: TokenEntry { + id: "__legacy__".to_string(), + token_hash: String::new(), + scopes: vec![Scope::Read, Scope::Write, Scope::Admin], + namespaces: vec!["*".to_string()], + expires_at: None, + description: "Legacy --auth-token (wildcard)".to_string(), + created_at: Utc::now(), + }, + }); + } + + // Look up in v2 store + match self.token_store.lookup_by_plaintext(bearer_token).await { + Some(entry) => { + if entry.is_expired() { + return Err(AuthDenial::Expired { + id: entry.id.clone(), + }); + } + Ok(AuthResult { token: entry }) + } + None => Err(AuthDenial::InvalidToken), + } + } + + /// Full authorization check: authenticate + scope + namespace. + pub async fn authorize( + &self, + bearer_token: &str, + required_scope: &Scope, + namespace: Option<&str>, + ) -> Result { + let result = self.authenticate(bearer_token).await?; + + // Check scope + if !result.token.has_scope(required_scope) { + return Err(AuthDenial::InsufficientScope { + id: result.token.id.clone(), + required: required_scope.clone(), + granted: result.token.scopes.clone(), + }); + } + + // Check namespace ACL (if a namespace is specified) + if let Some(ns) = namespace + && !result.token.has_namespace_access(ns) + { + return Err(AuthDenial::NamespaceDenied { + id: result.token.id.clone(), + requested: ns.to_string(), + allowed: result.token.namespaces.clone(), + }); + } + + Ok(result) + } + + /// Delegate to token store: create a new token. + pub async fn create_token( + &self, + id: String, + scopes: Vec, + namespaces: Vec, + expires_at: Option>, + description: String, + ) -> Result { + self.token_store + .create_token(id, scopes, namespaces, expires_at, description) + .await + } + + /// Delegate to token store: list all tokens. + pub async fn list_tokens(&self) -> Vec { + self.token_store.list_tokens().await + } + + /// Delegate to token store: revoke a token. + pub async fn revoke_token(&self, id: &str) -> Result { + self.token_store.revoke_token(id).await + } + + /// Delegate to token store: rotate a token. + pub async fn rotate_token(&self, id: &str) -> Result { + self.token_store.rotate_token(id).await + } + + /// Check if any tokens are configured (v2 or legacy). + pub async fn has_any_tokens(&self) -> bool { + self.legacy_token.is_some() || !self.token_store.list_tokens().await.is_empty() + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scope_display_and_parse() { + assert_eq!(Scope::Read.to_string(), "read"); + assert_eq!(Scope::Write.to_string(), "write"); + assert_eq!(Scope::Admin.to_string(), "admin"); + + assert_eq!(Scope::from_str("read").unwrap(), Scope::Read); + assert_eq!(Scope::from_str("WRITE").unwrap(), Scope::Write); + assert_eq!(Scope::from_str("Admin").unwrap(), Scope::Admin); + assert!(Scope::from_str("invalid").is_err()); + } + + #[test] + fn token_entry_scope_check() { + let entry = TokenEntry { + id: "test".to_string(), + token_hash: String::new(), + scopes: vec![Scope::Read], + namespaces: vec!["ns1".to_string()], + expires_at: None, + description: "test".to_string(), + created_at: Utc::now(), + }; + + assert!(entry.has_scope(&Scope::Read)); + assert!(!entry.has_scope(&Scope::Write)); + assert!(!entry.has_scope(&Scope::Admin)); + } + + #[test] + fn admin_scope_implies_all() { + let entry = TokenEntry { + id: "admin".to_string(), + token_hash: String::new(), + scopes: vec![Scope::Admin], + namespaces: vec!["*".to_string()], + expires_at: None, + description: "admin".to_string(), + created_at: Utc::now(), + }; + + assert!(entry.has_scope(&Scope::Read)); + assert!(entry.has_scope(&Scope::Write)); + assert!(entry.has_scope(&Scope::Admin)); + } + + #[test] + fn namespace_wildcard_access() { + let entry = TokenEntry { + id: "wild".to_string(), + token_hash: String::new(), + scopes: vec![Scope::Read], + namespaces: vec!["*".to_string()], + expires_at: None, + description: "wildcard".to_string(), + created_at: Utc::now(), + }; + + assert!(entry.has_namespace_access("kb:claude")); + assert!(entry.has_namespace_access("anything")); + } + + #[test] + fn namespace_acl_check() { + let entry = TokenEntry { + id: "limited".to_string(), + token_hash: String::new(), + scopes: vec![Scope::Read], + namespaces: vec!["kb:claude".to_string(), "kb:mikserka".to_string()], + expires_at: None, + description: "limited".to_string(), + created_at: Utc::now(), + }; + + assert!(entry.has_namespace_access("kb:claude")); + assert!(entry.has_namespace_access("kb:mikserka")); + assert!(!entry.has_namespace_access("kb:reports")); + } + + #[test] + fn token_entry_expiry() { + let expired = TokenEntry { + id: "expired".to_string(), + token_hash: String::new(), + scopes: vec![Scope::Read], + namespaces: vec!["*".to_string()], + expires_at: Some( + DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc), + ), + description: "expired".to_string(), + created_at: Utc::now(), + }; + assert!(expired.is_expired()); + + let future = TokenEntry { + id: "future".to_string(), + token_hash: String::new(), + scopes: vec![Scope::Read], + namespaces: vec!["*".to_string()], + expires_at: Some( + DateTime::parse_from_rfc3339("2099-12-31T00:00:00Z") + .unwrap() + .with_timezone(&Utc), + ), + description: "future".to_string(), + created_at: Utc::now(), + }; + assert!(!future.is_expired()); + + let no_expiry = TokenEntry { + id: "noexp".to_string(), + token_hash: String::new(), + scopes: vec![Scope::Read], + namespaces: vec!["*".to_string()], + expires_at: None, + description: "no expiry".to_string(), + created_at: Utc::now(), + }; + assert!(!no_expiry.is_expired()); + } + + #[tokio::test] + async fn token_create_and_lookup() { + let dir = tempfile::tempdir().unwrap(); + let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string(); + + let store = TokenStoreFile::new(store_path); + + let plaintext = store + .create_token( + "test-token".to_string(), + vec![Scope::Read, Scope::Write], + vec!["kb:claude".to_string()], + None, + "Test token".to_string(), + ) + .await + .unwrap(); + + assert!(plaintext.starts_with("memex_")); + + // Lookup should succeed + let found = store.lookup_by_plaintext(&plaintext).await; + assert!(found.is_some()); + let entry = found.unwrap(); + assert_eq!(entry.id, "test-token"); + assert_eq!(entry.scopes, vec![Scope::Read, Scope::Write]); + + // Wrong token should fail + let not_found = store.lookup_by_plaintext("memex_wrong").await; + assert!(not_found.is_none()); + } + + #[tokio::test] + async fn token_revoke() { + let dir = tempfile::tempdir().unwrap(); + let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string(); + + let store = TokenStoreFile::new(store_path); + let plaintext = store + .create_token( + "revokable".to_string(), + vec![Scope::Read], + vec!["*".to_string()], + None, + "Will be revoked".to_string(), + ) + .await + .unwrap(); + + // Verify it works + assert!(store.lookup_by_plaintext(&plaintext).await.is_some()); + + // Revoke + assert!(store.revoke_token("revokable").await.unwrap()); + + // Should no longer be found + assert!(store.lookup_by_plaintext(&plaintext).await.is_none()); + } + + #[tokio::test] + async fn auth_manager_scope_enforcement() { + let dir = tempfile::tempdir().unwrap(); + let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string(); + + let manager = AuthManager::new(store_path, None); + manager.init().await.unwrap(); + + let plaintext = manager + .create_token( + "read-only".to_string(), + vec![Scope::Read], + vec!["*".to_string()], + None, + "Read-only token".to_string(), + ) + .await + .unwrap(); + + // Read should succeed + let result = manager.authorize(&plaintext, &Scope::Read, None).await; + assert!(result.is_ok()); + + // Write should fail with InsufficientScope + let result = manager.authorize(&plaintext, &Scope::Write, None).await; + assert!(result.is_err()); + match result.unwrap_err() { + AuthDenial::InsufficientScope { required, .. } => { + assert_eq!(required, Scope::Write); + } + other => panic!("Expected InsufficientScope, got: {:?}", other), + } + } + + #[tokio::test] + async fn auth_manager_namespace_enforcement() { + let dir = tempfile::tempdir().unwrap(); + let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string(); + + let manager = AuthManager::new(store_path, None); + manager.init().await.unwrap(); + + let plaintext = manager + .create_token( + "ns-limited".to_string(), + vec![Scope::Read, Scope::Write], + vec!["kb:claude".to_string()], + None, + "Limited to kb:claude".to_string(), + ) + .await + .unwrap(); + + // Allowed namespace + let result = manager + .authorize(&plaintext, &Scope::Read, Some("kb:claude")) + .await; + assert!(result.is_ok()); + + // Disallowed namespace + let result = manager + .authorize(&plaintext, &Scope::Read, Some("kb:reports")) + .await; + assert!(result.is_err()); + match result.unwrap_err() { + AuthDenial::NamespaceDenied { requested, .. } => { + assert_eq!(requested, "kb:reports"); + } + other => panic!("Expected NamespaceDenied, got: {:?}", other), + } + } + + #[tokio::test] + async fn auth_manager_legacy_token() { + let dir = tempfile::tempdir().unwrap(); + let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string(); + + let manager = AuthManager::new(store_path, Some("my-legacy-token".to_string())); + manager.init().await.unwrap(); + + // Legacy token should have wildcard access + let result = manager + .authorize("my-legacy-token", &Scope::Admin, Some("any-ns")) + .await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().token.id, "__legacy__"); + + // Wrong token should fail + let result = manager.authenticate("wrong-token").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn auth_manager_expired_token() { + let dir = tempfile::tempdir().unwrap(); + let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string(); + + let manager = AuthManager::new(store_path, None); + manager.init().await.unwrap(); + + // Directly create an expired entry via the store + { + let store = &manager.token_store; + let argon2 = Argon2::default(); + let salt = SaltString::generate(&mut OsRng); + let hash = argon2 + .hash_password(b"expired_token_value", &salt) + .unwrap() + .to_string(); + + let entry = TokenEntry { + id: "expired-test".to_string(), + token_hash: hash, + scopes: vec![Scope::Read], + namespaces: vec!["*".to_string()], + expires_at: Some( + DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc), + ), + description: "Expired test".to_string(), + created_at: Utc::now(), + }; + let mut s = store.store.write().await; + s.tokens.push(entry); + } + + let result = manager.authenticate("expired_token_value").await; + assert!(result.is_err()); + match result.unwrap_err() { + AuthDenial::Expired { id } => assert_eq!(id, "expired-test"), + other => panic!("Expected Expired, got: {:?}", other), + } + } + + #[tokio::test] + async fn token_store_persistence() { + let dir = tempfile::tempdir().unwrap(); + let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string(); + + // Create and save + let store1 = TokenStoreFile::new(store_path.clone()); + let plaintext = store1 + .create_token( + "persist-test".to_string(), + vec![Scope::Read], + vec!["*".to_string()], + None, + "Persistence test".to_string(), + ) + .await + .unwrap(); + + // Load from fresh instance + let store2 = TokenStoreFile::new(store_path); + store2.load().await.unwrap(); + + let found = store2.lookup_by_plaintext(&plaintext).await; + assert!(found.is_some()); + assert_eq!(found.unwrap().id, "persist-test"); + } + + #[tokio::test] + async fn token_rotate() { + let dir = tempfile::tempdir().unwrap(); + let store_path = dir.path().join("tokens.json").to_str().unwrap().to_string(); + + let store = TokenStoreFile::new(store_path); + let old_plaintext = store + .create_token( + "rotate-me".to_string(), + vec![Scope::Read, Scope::Write], + vec!["kb:claude".to_string()], + None, + "Will be rotated".to_string(), + ) + .await + .unwrap(); + + // Rotate + let new_plaintext = store.rotate_token("rotate-me").await.unwrap(); + assert_ne!(old_plaintext, new_plaintext); + + // Old should not work + assert!(store.lookup_by_plaintext(&old_plaintext).await.is_none()); + + // New should work + let found = store.lookup_by_plaintext(&new_plaintext).await; + assert!(found.is_some()); + assert_eq!(found.unwrap().id, "rotate-me"); + } + + #[tokio::test] + async fn v1_migration() { + let dir = tempfile::tempdir().unwrap(); + let store_path = dir.path().join("tokens.json"); + + // Write a v1 store + let v1_data: std::collections::HashMap = [( + "kb:claude".to_string(), + serde_json::json!({ + "namespace": "kb:claude", + "token": "ns_test123456", + "created_at": 1700000000_u64, + "description": "Original v1 token" + }), + )] + .into_iter() + .collect(); + + tokio::fs::write(&store_path, serde_json::to_string_pretty(&v1_data).unwrap()) + .await + .unwrap(); + + // Load should migrate + let store = TokenStoreFile::new(store_path.to_str().unwrap().to_string()); + store.load().await.unwrap(); + + // Verify migration + let tokens = store.list_tokens().await; + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].id, "migrated-kb:claude"); + assert_eq!(tokens[0].namespaces, vec!["kb:claude".to_string()]); + assert_eq!( + tokens[0].scopes, + vec![Scope::Read, Scope::Write, Scope::Admin] + ); + + // Old plaintext should verify + let found = store.lookup_by_plaintext("ns_test123456").await; + assert!(found.is_some()); + + // Backup file should exist + let backup_path = format!("{}.v1.bak", store_path.to_str().unwrap()); + assert!(Path::new(&backup_path).exists()); + } +} diff --git a/crates/rust-memex/src/bin/cli/batch_policy.rs b/crates/rust-memex/src/bin/cli/batch_policy.rs new file mode 100644 index 0000000..b2f80c3 --- /dev/null +++ b/crates/rust-memex/src/bin/cli/batch_policy.rs @@ -0,0 +1,83 @@ +use anyhow::{Result, bail}; +use serde::Serialize; + +#[derive(Debug, Clone, Copy)] +pub struct BatchFailurePolicy { + pub strict: bool, + pub max_failure_rate: f64, +} + +impl BatchFailurePolicy { + pub fn new(strict: bool, max_failure_rate: f64) -> Result { + if !(0.0..=1.0).contains(&max_failure_rate) { + bail!("--max-failure-rate must be between 0.0 and 1.0"); + } + Ok(Self { + strict, + max_failure_rate, + }) + } + + pub fn should_fail(&self, summary: &BatchRunSummary) -> bool { + (self.strict && summary.failed > 0) || summary.failure_rate() > self.max_failure_rate + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct BatchRunSummary { + pub indexed: usize, + pub failed: usize, + pub total: usize, + pub failure_rate: f64, + pub errors: Vec, +} + +impl BatchRunSummary { + pub fn new(indexed: usize, failed: usize, total: usize, errors: Vec) -> Self { + let failure_rate = if total == 0 { + 0.0 + } else { + failed as f64 / total as f64 + }; + Self { + indexed, + failed, + total, + failure_rate, + errors, + } + } + + fn failure_rate(&self) -> f64 { + self.failure_rate + } + + pub fn emit_json(&self) -> Result<()> { + println!("{}", serde_json::to_string(self)?); + Ok(()) + } + + pub fn emit_warning(&self, noun: &str) { + if self.failed > 0 { + eprintln!( + "WARNING: {}/{} {} failed. See log above.", + self.failed, self.total, noun + ); + } + } + + pub fn enforce(&self, policy: BatchFailurePolicy, noun: &str) -> Result<()> { + if policy.should_fail(self) { + let reason = if policy.strict && self.failed > 0 { + format!("strict=true and {} failure(s) were observed", self.failed) + } else { + format!( + "failure_rate {:.4} > max_failure_rate {:.4}", + self.failure_rate, policy.max_failure_rate + ) + }; + bail!("{}/{} {} failed ({reason})", self.failed, self.total, noun); + } + Ok(()) + } +} diff --git a/src/bin/cli/config.rs b/crates/rust-memex/src/bin/cli/config.rs similarity index 77% rename from src/bin/cli/config.rs rename to crates/rust-memex/src/bin/cli/config.rs index c26723d..ab0f126 100644 --- a/src/bin/cli/config.rs +++ b/crates/rust-memex/src/bin/cli/config.rs @@ -5,8 +5,9 @@ use rust_memex::{ path_utils, }; +pub const DEFAULT_DB_PATH: &str = "~/.rmcp-servers/rust-memex/lancedb"; + /// Standard config discovery locations (in priority order) -#[allow(dead_code)] const CONFIG_SEARCH_PATHS: &[&str] = &[ "~/.rmcp-servers/rust-memex/config.toml", "~/.config/rust-memex/config.toml", @@ -14,7 +15,6 @@ const CONFIG_SEARCH_PATHS: &[&str] = &[ ]; /// Discover config file from standard locations -#[allow(dead_code)] fn discover_config() -> Option { // 1. Environment variable takes priority if let Ok(path) = std::env::var("RUST_MEMEX_CONFIG") { @@ -35,7 +35,6 @@ fn discover_config() -> Option { None } -#[allow(dead_code)] fn load_file_config(path: &str) -> Result { let (_canonical, contents) = path_utils::safe_read_to_string(path) .map_err(|e| anyhow::anyhow!("Cannot load config '{}': {}", path, e))?; @@ -43,7 +42,6 @@ fn load_file_config(path: &str) -> Result { } /// Load config from explicit path or discover from standard locations -#[allow(dead_code)] pub fn load_or_discover_config( explicit_path: Option<&str>, ) -> Result<(FileConfig, Option)> { @@ -62,6 +60,7 @@ pub fn load_or_discover_config( } #[derive(serde::Deserialize, Default, Clone)] +#[serde(deny_unknown_fields)] pub struct FileConfig { /// Legacy compatibility field. Parsed but ignored when building ServerConfig. pub mode: Option, @@ -89,17 +88,50 @@ pub struct FileConfig { pub maintenance: Option, /// Bearer token for HTTP auth (mutating endpoints) pub auth_token: Option, + /// Optional dashboard-only OIDC configuration + #[serde(default)] + pub dashboard_oidc: Option, /// Bind address for HTTP server (default: 127.0.0.1) pub bind_address: Option, /// Allowed CORS origins (comma-separated list) pub cors_origins: Option, + /// Auth mode: "mutating-only", "all-routes", or "namespace-acl" + pub auth_mode: Option, + /// Allow ?token= query parameter on read GETs + pub allow_query_token: Option, +} + +#[derive(serde::Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct DashboardOidcFileConfig { + pub issuer_url: String, + pub client_id: String, + #[serde(default)] + pub client_secret: Option, + #[serde(default)] + pub public_base_url: Option, + #[serde(default = "default_dashboard_oidc_scopes")] + pub scopes: Vec, +} + +pub fn default_dashboard_oidc_scopes() -> Vec { + vec![ + "openid".to_string(), + "profile".to_string(), + "email".to_string(), + ] } /// New embedding configuration from TOML #[derive(serde::Deserialize, Clone)] +#[serde(deny_unknown_fields)] pub struct EmbeddingsFileConfig { #[serde(default = "default_dimension")] pub required_dimension: usize, + #[serde(default = "default_max_batch_chars")] + pub max_batch_chars: usize, + #[serde(default = "default_max_batch_items")] + pub max_batch_items: usize, #[serde(default)] pub providers: Vec, #[serde(default)] @@ -110,10 +142,20 @@ pub fn default_dimension() -> usize { DEFAULT_REQUIRED_DIMENSION } +pub fn default_max_batch_chars() -> usize { + EmbeddingConfig::default().max_batch_chars +} + +pub fn default_max_batch_items() -> usize { + EmbeddingConfig::default().max_batch_items +} + impl Default for EmbeddingsFileConfig { fn default() -> Self { Self { required_dimension: default_dimension(), + max_batch_chars: default_max_batch_chars(), + max_batch_items: default_max_batch_items(), providers: vec![], reranker: None, } @@ -121,6 +163,7 @@ impl Default for EmbeddingsFileConfig { } #[derive(serde::Deserialize, Clone)] +#[serde(deny_unknown_fields)] pub struct ProviderFileConfig { pub name: String, pub base_url: String, @@ -140,6 +183,7 @@ pub fn default_endpoint() -> String { } #[derive(serde::Deserialize, Clone)] +#[serde(deny_unknown_fields)] pub struct RerankerFileConfig { pub base_url: String, pub model: String, @@ -153,6 +197,7 @@ pub fn default_rerank_endpoint() -> String { /// Legacy MLX embedding server configuration from TOML (deprecated) #[derive(serde::Deserialize, Default, Clone)] +#[serde(deny_unknown_fields)] pub struct MlxFileConfig { #[serde(default)] pub disabled: bool, @@ -183,6 +228,7 @@ impl MlxFileConfig { /// Maintenance configuration for automatic optimization #[derive(serde::Deserialize, Default, Clone)] +#[serde(deny_unknown_fields)] pub struct MaintenanceFileConfig { /// Enable automatic optimization when version threshold is exceeded #[serde(default)] @@ -202,6 +248,33 @@ pub fn default_version_threshold() -> usize { } impl FileConfig { + pub fn resolve_db_path( + cli_db_path: Option<&str>, + file_db_path: Option<&str>, + warn_on_default: bool, + expand: bool, + ) -> String { + let db_path = cli_db_path + .map(str::to_string) + .or_else(|| file_db_path.map(str::to_string)) + .unwrap_or_else(|| DEFAULT_DB_PATH.to_string()); + + if cli_db_path.is_none() && file_db_path.is_none() && warn_on_default { + let displayed_db_path = shellexpand::tilde(&db_path).to_string(); + eprintln!( + "WARN: config db_path not specified at top level, defaulting to {}. \ +If you intended a custom path, ensure `db_path` appears before any section such as [embeddings] in config.toml.", + displayed_db_path + ); + } + + if expand { + shellexpand::tilde(&db_path).to_string() + } else { + db_path + } + } + /// Convert to EmbeddingConfig - new format takes precedence over legacy pub fn resolve_embedding_config(&self) -> EmbeddingConfig { // New format takes precedence, even when it only overrides dimension or reranker. @@ -220,6 +293,8 @@ impl FileConfig { }; config.required_dimension = emb.required_dimension; + config.max_batch_chars = emb.max_batch_chars; + config.max_batch_items = emb.max_batch_items; if !emb.providers.is_empty() { config.providers = emb @@ -275,11 +350,12 @@ impl ResolvedConfig { let embedding_config = file_cfg.resolve_embedding_config(); let maintenance_config = file_cfg.maintenance.clone(); - let db_path = cli_db_path - .map(|s| s.to_string()) - .or_else(|| file_cfg.db_path.clone()) - .unwrap_or_else(|| "~/.rmcp-servers/rust-memex/lancedb".to_string()); - let db_path = shellexpand::tilde(&db_path).to_string(); + let db_path = FileConfig::resolve_db_path( + cli_db_path, + file_cfg.db_path.as_deref(), + config_path.is_some(), + true, + ); Ok(Self { file_cfg, diff --git a/crates/rust-memex/src/bin/cli/data.rs b/crates/rust-memex/src/bin/cli/data.rs new file mode 100644 index 0000000..c9ebdbb --- /dev/null +++ b/crates/rust-memex/src/bin/cli/data.rs @@ -0,0 +1,713 @@ +use anyhow::Result; +use futures::{StreamExt, pin_mut}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub use rust_memex::contracts::audit::{ + AuditRecommendation, AuditResult as NamespaceAuditResult, ChunkQuality, QualityTier, +}; +use rust_memex::{ + ChunkerKind, EmbeddingClient, EmbeddingConfig, RAGPipeline, ReindexJob, ReindexOutcome, + ReprocessJob, ReprocessOutcome, SchemaVersion, SliceMode, StorageManager, diagnostics, + export_namespace_jsonl_stream, import_jsonl_file, reindex_namespace, reprocess_jsonl_file, +}; + +use crate::cli::batch_policy::{BatchFailurePolicy, BatchRunSummary}; + +pub struct ReprocessConfig { + pub namespace: String, + pub input: PathBuf, + pub slice_mode: SliceMode, + pub chunker: Option, + pub preprocess: bool, + pub skip_existing: bool, + pub allow_duplicates: bool, + pub strict: bool, + pub max_failure_rate: f64, + pub json: bool, + pub dry_run: bool, + pub db_path: String, +} + +pub struct ReindexConfig { + pub source_namespace: String, + pub target_namespace: String, + pub slice_mode: SliceMode, + pub chunker: Option, + pub preprocess: bool, + pub skip_existing: bool, + pub allow_duplicates: bool, + pub strict: bool, + pub max_failure_rate: f64, + pub json: bool, + pub dry_run: bool, + pub db_path: String, +} + +fn slice_mode_name(slice_mode: SliceMode) -> &'static str { + match slice_mode { + SliceMode::Onion => "onion", + SliceMode::OnionFast => "onion-fast", + SliceMode::Flat => "flat", + } +} + +fn reprocess_summary(outcome: &ReprocessOutcome) -> BatchRunSummary { + let failed = outcome.failed_ids.len() + outcome.parse_errors; + let total = outcome.canonical_documents + outcome.parse_errors; + let mut errors = outcome + .failed_ids + .iter() + .map(|id| format!("document {id} failed")) + .collect::>(); + if outcome.parse_errors > 0 { + errors.push(format!( + "{} input record(s) failed to parse", + outcome.parse_errors + )); + } + BatchRunSummary::new( + outcome.indexed_documents + outcome.replaced_documents, + failed, + total, + errors, + ) +} + +fn reindex_summary(outcome: &ReindexOutcome) -> BatchRunSummary { + let errors = outcome + .failed_ids + .iter() + .map(|id| format!("document {id} failed")) + .collect(); + BatchRunSummary::new( + outcome.indexed_documents + outcome.replaced_documents, + outcome.failed_ids.len(), + outcome.canonical_documents, + errors, + ) +} + +/// Export a namespace to JSONL file for portable backup +pub async fn run_export( + namespace: String, + output: Option, + include_embeddings: bool, + db_path: String, +) -> Result<()> { + let storage = Arc::new(StorageManager::new_lance_only(&db_path).await?); + let stream = export_namespace_jsonl_stream(storage, namespace.clone(), include_embeddings); + pin_mut!(stream); + + let mut exported_count = 0usize; + match output { + Some(path) => { + use tokio::io::AsyncWriteExt; + + let mut file = tokio::fs::File::create(&path).await?; + while let Some(line) = stream.next().await { + let line = line?; + file.write_all(line.as_bytes()).await?; + exported_count += 1; + } + file.flush().await?; + eprintln!( + "Exported {} documents from '{}' to {:?}", + exported_count, namespace, path + ); + } + None => { + use tokio::io::AsyncWriteExt; + + let mut stdout = tokio::io::stdout(); + while let Some(line) = stream.next().await { + let line = line?; + stdout.write_all(line.as_bytes()).await?; + exported_count += 1; + } + stdout.flush().await?; + } + } + + if include_embeddings && exported_count > 0 { + eprintln!(" (embeddings included - file may be large)"); + } + Ok(()) +} + +/// Import documents from JSONL file into a namespace +pub async fn run_import( + namespace: String, + input: PathBuf, + skip_existing: bool, + db_path: String, + embedding_config: &EmbeddingConfig, +) -> Result<()> { + let storage = Arc::new(StorageManager::new_lance_only(&db_path).await?); + let embedding_client = Arc::new(Mutex::new(EmbeddingClient::new(embedding_config).await?)); + let rag = Arc::new(RAGPipeline::new(embedding_client, storage).await?); + let outcome = import_jsonl_file(rag, namespace.clone(), &input, skip_existing).await?; + + eprintln!(); + eprintln!("Import complete:"); + eprintln!(" Imported: {} documents", outcome.imported_count); + if outcome.skipped_count > 0 { + eprintln!(" Skipped: {} (already exist)", outcome.skipped_count); + } + if outcome.error_count > 0 { + eprintln!(" Errors: {}", outcome.error_count); + } + + Ok(()) +} + +/// Rebuild exported JSONL documents into a fresh namespace with current chunking. +pub async fn run_reprocess( + config: ReprocessConfig, + embedding_config: &EmbeddingConfig, +) -> Result<()> { + let ReprocessConfig { + namespace, + input, + slice_mode, + chunker, + preprocess, + skip_existing, + allow_duplicates, + strict, + max_failure_rate, + json, + dry_run, + db_path, + } = config; + let failure_policy = BatchFailurePolicy::new(strict, max_failure_rate)?; + + let storage = Arc::new(StorageManager::new_lance_only(&db_path).await?); + let embedding_client = Arc::new(Mutex::new(EmbeddingClient::new(embedding_config).await?)); + let rag = Arc::new(RAGPipeline::new(embedding_client, storage).await?); + + let outcome = reprocess_jsonl_file( + rag, + ReprocessJob { + input_path: input.clone(), + target_namespace: namespace.clone(), + slice_mode, + chunker, + preprocess, + skip_existing, + allow_duplicates, + dry_run, + }, + |_| {}, + ) + .await?; + + let collapsed_records = outcome + .source_records + .saturating_sub(outcome.canonical_documents); + eprintln!( + "Reprocessing {} source records into {} canonical documents for namespace '{}'...", + outcome.source_records, outcome.canonical_documents, namespace + ); + eprintln!(" Source: {}", outcome.source_label); + eprintln!(" Slice mode: {}", slice_mode_name(slice_mode)); + eprintln!( + " Preprocess: {}", + if preprocess { "enabled" } else { "disabled" } + ); + eprintln!( + " Collapsed: {} duplicate slice records", + collapsed_records + ); + if dry_run { + eprintln!(); + eprintln!("Dry run only: no documents were written."); + } + eprintln!(); + eprintln!("Reprocess complete:"); + eprintln!(" Indexed: {}", outcome.indexed_documents); + if outcome.replaced_documents > 0 { + eprintln!(" Replaced: {}", outcome.replaced_documents); + } + if outcome.skipped_existing_documents > 0 { + eprintln!(" Skipped existing: {}", outcome.skipped_existing_documents); + } + if outcome.skipped_empty_documents > 0 { + eprintln!(" Skipped empty: {}", outcome.skipped_empty_documents); + } + if outcome.skipped_preprocess_short_documents > 0 { + eprintln!( + " Skipped too short: {}", + outcome.skipped_preprocess_short_documents + ); + } + if !outcome.failed_ids.is_empty() { + eprintln!( + " FAILED: {} (IDs: {})", + outcome.failed_ids.len(), + if outcome.failed_ids.len() <= 10 { + outcome.failed_ids.join(", ") + } else { + format!( + "{}... and {} more", + outcome.failed_ids[..10].join(", "), + outcome.failed_ids.len() - 10 + ) + } + ); + } + if outcome.parse_errors > 0 { + eprintln!(" Parse errors: {}", outcome.parse_errors); + } + let summary = reprocess_summary(&outcome); + if json { + summary.emit_json()?; + } else { + summary.emit_warning("documents"); + } + summary.enforce(failure_policy, "documents")?; + Ok(()) +} + +pub async fn run_reindex(config: ReindexConfig, embedding_config: &EmbeddingConfig) -> Result<()> { + let ReindexConfig { + source_namespace, + target_namespace, + slice_mode, + chunker, + preprocess, + skip_existing, + allow_duplicates, + strict, + max_failure_rate, + json, + dry_run, + db_path, + } = config; + let failure_policy = BatchFailurePolicy::new(strict, max_failure_rate)?; + + let storage = Arc::new(StorageManager::new_lance_only(&db_path).await?); + let embedding_client = Arc::new(Mutex::new(EmbeddingClient::new(embedding_config).await?)); + let rag = Arc::new(RAGPipeline::new(embedding_client, storage).await?); + + let outcome = reindex_namespace( + rag, + ReindexJob { + source_namespace: source_namespace.clone(), + target_namespace: target_namespace.clone(), + slice_mode, + chunker, + preprocess, + skip_existing, + allow_duplicates, + dry_run, + }, + |_| {}, + ) + .await?; + + if dry_run { + eprintln!("Dry run only: no documents were written."); + } + eprintln!( + "Reindexed {} source rows into {} canonical documents from '{}' to '{}'", + outcome.source_records, + outcome.canonical_documents, + outcome.source_namespace, + outcome.target_namespace + ); + eprintln!(" Indexed: {}", outcome.indexed_documents); + if outcome.replaced_documents > 0 { + eprintln!(" Replaced: {}", outcome.replaced_documents); + } + if outcome.skipped_documents > 0 { + eprintln!(" Skipped: {}", outcome.skipped_documents); + } + if !outcome.failed_ids.is_empty() { + eprintln!(" Failed: {}", outcome.failed_ids.len()); + } + let summary = reindex_summary(&outcome); + if json { + summary.emit_json()?; + } else { + summary.emit_warning("documents"); + } + summary.enforce(failure_policy, "documents")?; + Ok(()) +} + +// ============================================================================= +// AUDIT & PURGE QUALITY COMMANDS +// ============================================================================= + +/// Run audit on namespaces to check quality metrics +pub async fn run_audit( + namespace: Option, + threshold: u8, + verbose: bool, + json: bool, + db_path: String, +) -> Result<()> { + let storage = StorageManager::new_lance_only(&db_path).await?; + + let namespaces: Vec = if let Some(ns) = namespace.as_deref() { + vec![ns.to_string()] + } else { + storage + .list_namespaces() + .await? + .into_iter() + .map(|(name, _count)| name) + .collect() + }; + + if namespaces.is_empty() { + if json { + println!(r#"{{"namespaces": [], "summary": {{"total": 0}}}}"#); + } else { + eprintln!("No namespaces found in database"); + } + return Ok(()); + } + + let results = diagnostics::audit_namespaces(&storage, namespace.as_deref(), threshold).await?; + + if !json { + eprintln!( + "Auditing {} namespace(s) with {}% quality threshold...\n", + namespaces.len(), + threshold + ); + } + + if verbose && !json { + for result in &results { + eprintln!("Namespace: {}", result.namespace); + eprintln!(" Documents: {}", result.document_count); + eprintln!( + " avg_chunk_length={} sentence_integrity={:.2} word_integrity={:.2} chunk_quality={:.2} overall={:.2}", + result.avg_chunk_length, + result.sentence_integrity, + result.word_integrity, + result.chunk_quality, + result.overall_score + ); + eprintln!(); + } + } + + // Output results + if json { + let passing = results.iter().filter(|r| r.passes_threshold).count(); + let failing = results.len() - passing; + + let output = serde_json::json!({ + "namespaces": results, + "summary": { + "total": results.len(), + "passing": passing, + "failing": failing, + "threshold": threshold + } + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + // Human-readable output + println!("╔════════════════════════════════════════════════════════════════╗"); + println!("║ NAMESPACE QUALITY AUDIT ║"); + println!("╠════════════════════════════════════════════════════════════════╣"); + println!( + "║ {:30} │ {:>6} │ {:>6} │ {:>8} ║", + "Namespace", "Docs", "Score", "Status" + ); + println!("╠════════════════════════════════════════════════════════════════╣"); + + for result in &results { + let status_icon = if result.passes_threshold { + "✅" + } else { + "❌" + }; + let ns_display = if result.namespace.len() > 28 { + format!("{}...", &result.namespace[..25]) + } else { + result.namespace.clone() + }; + + println!( + "║ {:30} │ {:>6} │ {:>5.1}% │ {} {:>6} ║", + ns_display, + result.document_count, + result.overall_score * 100.0, + status_icon, + result.recommendation + ); + } + + println!("╚════════════════════════════════════════════════════════════════╝"); + + let passing = results.iter().filter(|r| r.passes_threshold).count(); + let failing = results.len() - passing; + + println!(); + println!( + "Summary: {} passing, {} failing (threshold: {}%)", + passing, failing, threshold + ); + + if failing > 0 { + println!(); + println!("Namespaces below threshold:"); + for result in results.iter().filter(|r| !r.passes_threshold) { + println!( + " - {} ({:.1}% quality, {} docs)", + result.namespace, + result.overall_score * 100.0, + result.document_count + ); + } + println!(); + println!( + "Run 'rust-memex purge-quality --threshold {}' to remove low-quality namespaces", + threshold + ); + } + } + + Ok(()) +} + +/// Backfill per-chunk `content_hash` and `source_hash` for legacy chunks. +/// +/// Closes the operator surface for spec P0 backfill: the lib API +/// (`diagnostics::backfill_chunk_and_source_hashes`) and the HTTP endpoint +/// (`POST /api/backfill-hashes`) were added in marbles round 001, but the +/// only way to invoke them from the shell was through `curl` against the +/// running server. This command makes the backfill reachable from the same +/// CLI surface as `dedup` / `audit`, which is what the spec asked for: +/// "Uruchomić jako `rust-memex backfill-hashes --namespace ` lub +/// podobne". +/// +/// Defaults to `--dry-run true` so an operator can preview before writing. +/// Spec: `2026-04-27_kb-transcripts-onion-slicer-fix-spec.md`, P0 backfill. +pub async fn run_backfill_hashes( + namespace: Option, + dry_run: bool, + json: bool, + strict: bool, + max_failure_rate: f64, + db_path: String, +) -> Result<()> { + let failure_policy = BatchFailurePolicy::new(strict, max_failure_rate)?; + let storage = StorageManager::new_lance_only(&db_path).await?; + + if !json { + let scope = namespace + .as_deref() + .map(|ns| format!("namespace '{}'", ns)) + .unwrap_or_else(|| "all namespaces".to_string()); + eprintln!( + "Backfilling content_hash + source_hash across {} (dry_run={})...", + scope, dry_run + ); + } + + let result = + diagnostics::backfill_chunk_and_source_hashes(&storage, namespace.as_deref(), dry_run) + .await?; + + let summary = BatchRunSummary::new( + result.content_hash_backfilled + result.source_hash_backfilled, + result.skipped_no_embedding, + result.total_docs, + if result.skipped_no_embedding > 0 { + vec![format!( + "{} document(s) could not be backfilled because embedding was missing", + result.skipped_no_embedding + )] + } else { + Vec::new() + }, + ); + + if json { + summary.emit_json()?; + summary.enforce(failure_policy, "documents")?; + return Ok(()); + } + + println!("╔════════════════════════════════════════════════════════════════╗"); + println!("║ BACKFILL HASHES SUMMARY ║"); + println!("╠════════════════════════════════════════════════════════════════╣"); + println!( + "║ {:48} {:>13} ║", + "Total documents inspected", result.total_docs + ); + println!( + "║ {:48} {:>13} ║", + "content_hash backfilled (per-chunk SHA256)", result.content_hash_backfilled + ); + println!( + "║ {:48} {:>13} ║", + "source_hash backfilled (recovered legacy)", result.source_hash_backfilled + ); + println!( + "║ {:48} {:>13} ║", + "Already consistent (skipped)", result.already_consistent + ); + println!( + "║ {:48} {:>13} ║", + "Skipped (no embedding)", result.skipped_no_embedding + ); + println!("╚════════════════════════════════════════════════════════════════╝"); + + if result.dry_run { + let touched = result.content_hash_backfilled + result.source_hash_backfilled; + if touched > 0 { + println!(); + println!( + "DRY RUN - {} document(s) would be rewritten. Re-run with --dry-run false to apply.", + touched + ); + } else { + println!(); + println!("DRY RUN - nothing would change. Backfill is a no-op for this scope."); + } + } else { + println!(); + println!( + "Wrote {} content_hash + {} source_hash updates.", + result.content_hash_backfilled, result.source_hash_backfilled + ); + } + + summary.emit_warning("documents"); + summary.enforce(failure_policy, "documents")?; + + Ok(()) +} + +pub async fn run_migrate_schema( + target: SchemaVersion, + check_only: bool, + db_path: String, +) -> Result<()> { + let report = StorageManager::migrate_lance_schema(&db_path, target, check_only).await?; + let missing_names = report.missing_column_names(); + + if missing_names.is_empty() { + println!( + "Schema is up-to-date (target={}). No migration needed.", + report.target + ); + return Ok(()); + } + + if check_only { + println!("Migration needed. Missing columns: {missing_names:?}"); + std::process::exit(1); + } + + println!( + "Migrating schema to {}: adding {} columns", + report.target, + report.missing_columns.len() + ); + for field in &report.missing_columns { + println!( + " + adding column: {} ({:?})", + field.name(), + field.data_type() + ); + } + println!("Migration complete. Schema is now {}.", report.target); + Ok(()) +} + +/// Purge namespaces below quality threshold +pub async fn run_purge_quality( + threshold: u8, + confirm: bool, + json: bool, + db_path: String, +) -> Result<()> { + let storage = StorageManager::new_lance_only(&db_path).await?; + let namespace_list = storage.list_namespaces().await?; + + if namespace_list.is_empty() { + if json { + println!(r#"{{"purged": [], "dry_run": {}}}"#, !confirm); + } else { + eprintln!("No namespaces found in database"); + } + return Ok(()); + } + + if !json { + eprintln!( + "Analyzing {} namespace(s) with {}% quality threshold...\n", + namespace_list.len(), + threshold + ); + } + let result = diagnostics::purge_quality_namespaces(&storage, None, threshold, !confirm).await?; + + if result.candidates.is_empty() { + if json { + println!(r#"{{"purged": [], "message": "All namespaces pass quality threshold"}}"#); + } else { + println!( + "All namespaces pass the {}% quality threshold. Nothing to purge.", + threshold + ); + } + return Ok(()); + } + + if json { + let output = serde_json::json!({ + "dry_run": !confirm, + "threshold": threshold, + "to_purge": result.candidates.iter().map(|candidate| { + serde_json::json!({ + "namespace": candidate.namespace, + "quality_score": candidate.quality_score, + "document_count": candidate.document_count + }) + }).collect::>() + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!( + "Found {} namespace(s) below {}% quality threshold:", + result.candidates.len(), + threshold + ); + for candidate in &result.candidates { + println!( + " - {} ({:.1}% quality, {} docs)", + candidate.namespace, + candidate.quality_score * 100.0, + candidate.document_count + ); + } + println!(); + + if !confirm { + println!("DRY RUN - No changes made."); + println!("Run with --confirm to actually delete these namespaces."); + return Ok(()); + } + } + + if !json { + println!(); + println!( + "Purged {} namespace(s) with quality below {}%", + result.purged_namespaces, threshold + ); + } + + Ok(()) +} diff --git a/src/bin/cli/definition.rs b/crates/rust-memex/src/bin/cli/definition.rs similarity index 63% rename from src/bin/cli/definition.rs rename to crates/rust-memex/src/bin/cli/definition.rs index e8ee8a9..f22cdd4 100644 --- a/src/bin/cli/definition.rs +++ b/crates/rust-memex/src/bin/cli/definition.rs @@ -4,12 +4,11 @@ use std::path::{Path, PathBuf}; use tracing::Level; use walkdir::WalkDir; -use rust_memex::{NamespaceSecurityConfig, ServerConfig, path_utils}; +use rust_memex::{ChunkerKind, NamespaceSecurityConfig, SchemaVersion, ServerConfig, path_utils}; pub const DEFAULT_DASHBOARD_PORT: u16 = 8987; pub const DEFAULT_SSE_PORT: u16 = 8997; /// Standard config discovery locations (in priority order) -#[allow(dead_code)] const CONFIG_SEARCH_PATHS: &[&str] = &[ "~/.rmcp-servers/rust-memex/config.toml", "~/.config/rust-memex/config.toml", @@ -17,7 +16,6 @@ const CONFIG_SEARCH_PATHS: &[&str] = &[ ]; /// Discover config file from standard locations -#[allow(dead_code)] fn discover_config() -> Option { // 1. Environment variable takes priority if let Ok(path) = std::env::var("RUST_MEMEX_CONFIG") { @@ -38,7 +36,6 @@ fn discover_config() -> Option { None } -#[allow(dead_code)] fn load_file_config(path: &str) -> Result { let (_canonical, contents) = path_utils::safe_read_to_string(path) .map_err(|e| anyhow::anyhow!("Cannot load config '{}': {}", path, e))?; @@ -46,7 +43,6 @@ fn load_file_config(path: &str) -> Result { } /// Load config from explicit path or discover from standard locations -#[allow(dead_code)] fn load_or_discover_config(explicit_path: Option<&str>) -> Result<(FileConfig, Option)> { // Explicit path takes priority if let Some(path) = explicit_path { @@ -133,7 +129,13 @@ pub struct Cli { #[arg(long, global = true)] pub http_only: bool, - /// Bearer token for authenticating mutating HTTP endpoints. + /// Migrate an older LanceDB schema at daemon startup instead of refusing to start. + /// Default is fail-fast; run `rust-memex migrate-schema --db-path ` for manual control. + #[arg(long, global = true)] + pub auto_migrate: bool, + + /// Bearer token for authenticating HTTP endpoints. + /// API/SSE/MCP access stays Bearer even when dashboard OIDC is enabled. /// Can also be set via MEMEX_AUTH_TOKEN env var. #[arg(long, global = true)] pub auth_token: Option, @@ -147,6 +149,25 @@ pub struct Cli { /// when bound to non-localhost, or permissive when bound to localhost. #[arg(long, global = true)] pub cors_origins: Option, + + /// Allow binding to non-loopback addresses without --auth-token. + /// By default, binding to e.g. 0.0.0.0 without auth is a hard error. + /// This flag downgrades it to a warning. + #[arg(long, global = true)] + pub allow_network_without_auth: bool, + + /// Auth enforcement mode for HTTP endpoints. + /// - mutating-only (default): bearer required only on mutating + MCP routes + /// - all-routes: bearer required on ALL routes + /// - namespace-acl: reserved for Track C (namespace-level ACL) + #[arg(long, global = true, default_value = "mutating-only", + value_parser = ["mutating-only", "all-routes", "namespace-acl"])] + pub auth_mode: String, + + /// Allow passing bearer token as ?token= query parameter on read GET endpoints. + /// Disabled by default. Only effective when --auth-mode is all-routes. + #[arg(long, global = true)] + pub allow_query_token: bool, } #[derive(Subcommand, Debug)] @@ -229,8 +250,12 @@ pub enum Commands { /// Batch index documents into vector store Index { /// Path to file or directory to index - #[arg(required = true)] - path: PathBuf, + #[arg(value_name = "PATH", required_unless_present = "source")] + path: Option, + + /// Path to file or directory to index + #[arg(long, value_name = "PATH", conflicts_with = "path")] + source: Option, /// Namespace for indexed documents (default: "rag") #[arg(long, short = 'n')] @@ -267,12 +292,71 @@ pub enum Commands { #[arg(long, short = 's', default_value = "onion", value_parser = ["onion", "onion-fast", "fast", "flat"])] slice_mode: String, + /// Chunk provider override. If omitted, rust-memex routes per namespace/path. + #[arg(long, value_enum)] + chunker: Option, + + /// Outer-layer synthesis strategy for onion modes (spec P3). + /// - "keyword" (default): TF-based keyword extraction. No I/O. + /// - "llm": Synthesize the outer layer via a local Ollama model. Requires --pipeline mode. + /// + /// When set to "llm" the slicer POSTs each document to + /// `{ollama-endpoint}/api/generate` with `{ollama-model, stream: false}` + /// and replaces the keyword outer with the model's 1-3 sentence summary. + /// Failures (network, non-2xx, malformed JSON, empty completion) silently + /// fall back to the keyword outer so the pipeline never stalls. + /// + /// Reachable only through --pipeline mode; the legacy non-pipeline path + /// always uses the keyword outer regardless of this flag, so passing + /// --outer-synthesis llm without --pipeline is rejected up-front. + #[arg(long, default_value = "keyword", value_parser = ["keyword", "llm"])] + outer_synthesis: String, + + /// Ollama model name used when --outer-synthesis llm is set. + /// Spec P3 baseline: a small local model such as qwen2.5:3b or phi-3.5:mini + /// fits the 1-3 sentence summary budget cheaply. + #[arg(long, default_value = "qwen2.5:3b")] + ollama_model: String, + + /// Ollama HTTP endpoint used when --outer-synthesis llm is set. + /// Defaults to a local Ollama daemon. Trailing slash is normalized. + #[arg(long, default_value = "http://localhost:11434")] + ollama_endpoint: String, + /// Enable exact-match deduplication (default: enabled). /// Skips indexing files whose content already exists in the namespace. /// Uses SHA256 hash of original content before any preprocessing. #[arg(long, default_value = "true", action = clap::ArgAction::Set)] dedup: bool, + /// Force re-indexing even when the source already exists in the namespace + /// (spec P4 escape hatch). + /// + /// Equivalent to passing `--dedup false` but more explicit at the call + /// site: this is the operator-visible knob for "I know this source is + /// already indexed, re-embed it anyway." Use cases per spec P4: force + /// reindex after a slicer change, debug a specific document, or + /// rebuild a layer that was partially purged. + /// + /// Takes precedence over `--dedup` when set, so callers do not need + /// to pass both flags. The skip-log line for already-indexed sources + /// stays at `info!` level so an operator can tell from the run log + /// whether dedup was active and which sources were collapsed. + #[arg(long)] + allow_duplicates: bool, + + /// Exit non-zero if any file failed to index + #[arg(long)] + strict: bool, + + /// Exit non-zero if failure rate exceeds threshold (0.0-1.0) + #[arg(long, default_value = "1.0")] + max_failure_rate: f64, + + /// Emit JSON summary on stdout as the last line + #[arg(long)] + json: bool, + /// Show progress bar with ETA when running in an interactive terminal. /// Non-interactive runs fall back to line logs. #[arg(long)] @@ -693,17 +777,24 @@ pub enum Commands { json: bool, }, - /// Find and remove duplicate documents based on content hash + /// Find and remove duplicate documents based on the chosen grouping key /// - /// Groups documents by content_hash and removes duplicates, keeping one - /// document per unique content based on the --keep strategy. + /// Groups documents per --group-by and removes duplicates, keeping one + /// document per group based on the --keep strategy. The default + /// `source-hash-layer` grouping preserves the onion structure: for each + /// source document it keeps exactly one chunk per layer, removing only + /// real repeats (e.g. `__dupe__` + `__clean__` variants of the same + /// transcript). Use `content-hash` only when you actually want byte- + /// identical chunk text grouping (legacy v3 behavior). /// /// Examples: - /// rust-memex dedup # All namespaces, dry-run - /// rust-memex dedup -n memories # Specific namespace - /// rust-memex dedup --dry-run false # Actually remove duplicates - /// rust-memex dedup --keep newest # Keep newest duplicates - /// rust-memex dedup --cross-namespace # Dedup across all namespaces + /// rust-memex dedup # All namespaces, dry-run + /// rust-memex dedup -n kb:transcripts # Specific namespace + /// rust-memex dedup --dry-run false # Actually remove duplicates + /// rust-memex dedup --keep newest # Keep newest duplicates + /// rust-memex dedup --cross-namespace # Dedup across all namespaces + /// rust-memex dedup --group-by content-hash # Legacy per-chunk hash + /// rust-memex dedup --group-by source-hash # Collapse all layers per source Dedup { /// Specific namespace to deduplicate (if not set, processes all namespaces separately) #[arg(long, short = 'n')] @@ -725,6 +816,17 @@ pub enum Commands { #[arg(long)] cross_namespace: bool, + /// How to bucket chunks into duplicate groups. + /// - "source-hash-layer" (default): keeps onion intact, removes only true source repeats + /// - "source-hash": collapses all layers of a source into one group + /// - "content-hash": legacy per-chunk text hash (post-v4 finds nothing on fresh indexes) + #[arg( + long = "group-by", + default_value = "source-hash-layer", + value_parser = ["source-hash-layer", "source-hash", "content-hash"] + )] + group_by: String, + /// Output as JSON instead of human-readable format #[arg(long)] json: bool, @@ -840,6 +942,10 @@ pub enum Commands { #[arg(long, short = 's', default_value = "onion", value_parser = ["onion", "onion-fast", "fast", "flat"])] slice_mode: String, + /// Chunk provider override. If omitted, rust-memex routes by source/namespace heuristic. + #[arg(long, value_enum)] + chunker: Option, + /// Apply preprocessing before rebuilding documents #[arg(long)] preprocess: bool, @@ -848,6 +954,22 @@ pub enum Commands { #[arg(long)] skip_existing: bool, + /// Force rebuilding even when source_hash already exists in the target namespace + #[arg(long)] + allow_duplicates: bool, + + /// Exit non-zero if any document failed to reprocess + #[arg(long)] + strict: bool, + + /// Exit non-zero if failure rate exceeds threshold (0.0-1.0) + #[arg(long, default_value = "1.0")] + max_failure_rate: f64, + + /// Emit JSON summary on stdout as the last line + #[arg(long)] + json: bool, + /// Show what would be rebuilt without writing anything #[arg(long)] dry_run: bool, @@ -881,6 +1003,10 @@ pub enum Commands { #[arg(long, short = 's', default_value = "onion", value_parser = ["onion", "onion-fast", "fast", "flat"])] slice_mode: String, + /// Chunk provider override. If omitted, rust-memex routes by source/namespace heuristic. + #[arg(long, value_enum)] + chunker: Option, + /// Apply preprocessing before rebuilding documents #[arg(long)] preprocess: bool, @@ -889,6 +1015,22 @@ pub enum Commands { #[arg(long)] skip_existing: bool, + /// Force rebuilding even when source_hash already exists in the target namespace + #[arg(long)] + allow_duplicates: bool, + + /// Exit non-zero if any document failed to reindex + #[arg(long)] + strict: bool, + + /// Exit non-zero if failure rate exceeds threshold (0.0-1.0) + #[arg(long, default_value = "1.0")] + max_failure_rate: f64, + + /// Emit JSON summary on stdout as the last line + #[arg(long)] + json: bool, + /// Show what would be rebuilt without writing anything #[arg(long)] dry_run: bool, @@ -950,6 +1092,145 @@ pub enum Commands { #[arg(long)] json: bool, }, + + /// Migrate the LanceDB table schema to the target binary contract + /// + /// Adds missing nullable columns in-place without rewriting rows. This closes + /// the pre-v4 -> v4 chicken-and-egg where `backfill-hashes` needs the + /// `source_hash` column before it can repair legacy hash data. + /// + /// Examples: + /// rust-memex migrate-schema --check-only + /// rust-memex migrate-schema --db-path /path/to/lancedb + /// rust-memex migrate-schema --target v4 + MigrateSchema { + /// Target schema version (default: v4) + #[arg(long, default_value_t = SchemaVersion::current())] + target: SchemaVersion, + + /// Report whether migration is needed without changing the table. + /// Exits 1 when required columns are missing. + #[arg(long)] + check_only: bool, + }, + + /// Backfill per-chunk `content_hash` and `source_hash` for legacy chunks + /// + /// Walks the namespace (or every namespace when `-n` is omitted) and + /// recomputes `content_hash = SHA256(chunk_text)` for every row, recovering + /// the legacy source-text hash into the new `source_hash` column. Idempotent: + /// rows that already match the v4 contract are counted as "consistent" and + /// left alone. Pre-v4 chunks (single hash equal to source text) get the + /// hash promoted into `source_hash` so post-v4 dedup grouping works without + /// re-reading source files. + /// + /// Defaults to `--dry-run true` so an operator can audit before writing. + /// + /// Spec: `2026-04-27_kb-transcripts-onion-slicer-fix-spec.md`, P0 backfill. + /// + /// Examples: + /// rust-memex backfill-hashes # All namespaces, dry-run + /// rust-memex backfill-hashes -n kb:transcripts # One namespace, dry-run + /// rust-memex backfill-hashes -n kb:transcripts --dry-run false # Actually write + /// rust-memex backfill-hashes --json # Machine-readable + BackfillHashes { + /// Specific namespace to backfill (default: every namespace) + #[arg(long, short = 'n')] + namespace: Option, + + /// Plan only, write nothing (default: true). Pass `--dry-run false` + /// to actually rewrite the rows. Mirrors the `dedup` CLI default so + /// an operator never accidentally rewrites a namespace. + #[arg(long, default_value = "true", action = clap::ArgAction::Set)] + dry_run: bool, + + /// Output as JSON instead of human-readable format + #[arg(long)] + json: bool, + + /// Exit non-zero if any document could not be backfilled + #[arg(long)] + strict: bool, + + /// Exit non-zero if failure rate exceeds threshold (0.0-1.0) + #[arg(long, default_value = "1.0")] + max_failure_rate: f64, + }, + + /// Manage auth tokens with per-token scopes and namespace ACL + /// + /// Create, list, revoke, and rotate bearer tokens for HTTP API access. + /// Each token is hashed with argon2id at rest. The plaintext is shown + /// ONCE on creation and can never be retrieved again. + /// + /// Examples: + /// rust-memex auth create --description "iPhone" --scopes read,write --namespaces kb:claude,kb:mikserka + /// rust-memex auth list + /// rust-memex auth revoke --id monika-iphone + /// rust-memex auth rotate --id monika-iphone + Auth { + #[command(subcommand)] + action: AuthAction, + }, +} + +/// Auth token management subcommands. +#[derive(Subcommand, Debug)] +pub enum AuthAction { + /// Create a new auth token + /// + /// Generates a new token with specified scopes and namespace access. + /// The plaintext token is printed ONCE and never stored. + Create { + /// Human-readable token identifier (e.g., "monika-iphone") + #[arg(long)] + id: Option, + + /// Description of what this token is for + #[arg(long, required = true)] + description: String, + + /// Comma-separated scopes: read, write, admin + #[arg(long, default_value = "read,write")] + scopes: String, + + /// Comma-separated namespace ACL. Use "*" for all namespaces. + #[arg(long, default_value = "*")] + namespaces: String, + + /// Token expiry (RFC 3339 timestamp, e.g., "2026-12-31T00:00:00Z") + #[arg(long)] + expires_at: Option, + + /// Output as JSON instead of human-readable format + #[arg(long)] + json: bool, + }, + + /// List all tokens (without revealing plaintext) + List { + /// Output as JSON instead of human-readable format + #[arg(long)] + json: bool, + }, + + /// Revoke (delete) a token by its ID + Revoke { + /// Token ID to revoke + #[arg(long, required = true)] + id: String, + }, + + /// Rotate a token: revoke old, create new with same metadata + Rotate { + /// Token ID to rotate + #[arg(long, required = true)] + id: String, + + /// Output as JSON instead of human-readable format + #[arg(long)] + json: bool, + }, } impl Cli { @@ -970,20 +1251,32 @@ impl Cli { // Extract embedding config first (before any moves from file_cfg) let embeddings = file_cfg.resolve_embedding_config(); let default_cfg = ServerConfig::default(); + let db_path = FileConfig::resolve_db_path( + self.db_path.as_deref(), + file_cfg.db_path.as_deref(), + config_path.is_some(), + false, + ); // Build security config from CLI and file settings let security_enabled = self.security_enabled || file_cfg.security_enabled.unwrap_or(false); let token_store_path = self.token_store_path.or(file_cfg.token_store_path); + // Derive BM25 index path as sibling of db_path: + // db_path = "~/.rmcp-servers/rmcp-memex/lancedb" + // bm25 = "~/.rmcp-servers/rmcp-memex/bm25" + let mut hybrid = default_cfg.hybrid; + let expanded_db = shellexpand::tilde(&db_path).to_string(); + if let Some(parent) = std::path::Path::new(&expanded_db).parent() { + hybrid.bm25.index_path = parent.join("bm25").to_string_lossy().to_string(); + } + Ok(ServerConfig { cache_mb: self .cache_mb .or(file_cfg.cache_mb) .unwrap_or(default_cfg.cache_mb), - db_path: self - .db_path - .or(file_cfg.db_path) - .unwrap_or(default_cfg.db_path), + db_path, max_request_bytes: self .max_request_bytes .or(file_cfg.max_request_bytes) @@ -1002,7 +1295,7 @@ impl Cli { token_store_path, }, embeddings, - hybrid: default_cfg.hybrid, + hybrid, }) } } @@ -1148,4 +1441,283 @@ mod tests { other => panic!("expected repair-writes command, got {:?}", other), } } + + #[test] + fn auth_mode_flag_parses_all_routes() { + let cli = Cli::parse_from(["rust-memex", "--auth-mode", "all-routes", "serve"]); + assert_eq!(cli.auth_mode, "all-routes"); + } + + #[test] + fn auth_mode_defaults_to_mutating_only() { + let cli = Cli::parse_from(["rust-memex", "serve"]); + assert_eq!(cli.auth_mode, "mutating-only"); + } + + #[test] + fn allow_network_without_auth_parses() { + let cli = Cli::parse_from(["rust-memex", "--allow-network-without-auth", "serve"]); + assert!(cli.allow_network_without_auth); + } + + #[test] + fn allow_query_token_parses() { + let cli = Cli::parse_from(["rust-memex", "--allow-query-token", "serve"]); + assert!(cli.allow_query_token); + } + + #[test] + fn auth_mode_rejects_invalid_value() { + let result = Cli::try_parse_from(["rust-memex", "--auth-mode", "bogus", "serve"]); + assert!(result.is_err()); + } + + #[test] + fn index_command_outer_synthesis_defaults_to_keyword() { + let cli = Cli::parse_from(["rust-memex", "index", "/tmp"]); + match cli.command { + Some(Commands::Index { + outer_synthesis, + ollama_model, + ollama_endpoint, + .. + }) => { + assert_eq!(outer_synthesis, "keyword"); + // Defaults are still populated even on the keyword path so the + // CLI surface stays consistent; downstream parser ignores them. + assert_eq!(ollama_model, "qwen2.5:3b"); + assert_eq!(ollama_endpoint, "http://localhost:11434"); + } + other => panic!("expected index command, got {:?}", other), + } + } + + #[test] + fn index_command_accepts_outer_synthesis_llm_with_overrides() { + let cli = Cli::parse_from([ + "rust-memex", + "index", + "/tmp", + "--slice-mode", + "onion", + "--pipeline", + "--outer-synthesis", + "llm", + "--ollama-model", + "phi-3.5:mini", + "--ollama-endpoint", + "http://10.0.0.5:11434", + ]); + match cli.command { + Some(Commands::Index { + outer_synthesis, + ollama_model, + ollama_endpoint, + pipeline, + slice_mode, + .. + }) => { + assert_eq!(outer_synthesis, "llm"); + assert_eq!(ollama_model, "phi-3.5:mini"); + assert_eq!(ollama_endpoint, "http://10.0.0.5:11434"); + assert!(pipeline); + assert_eq!(slice_mode, "onion"); + } + other => panic!("expected index command, got {:?}", other), + } + } + + #[test] + fn index_command_rejects_unknown_outer_synthesis() { + let result = Cli::try_parse_from([ + "rust-memex", + "index", + "/tmp", + "--outer-synthesis", + "transformers", + ]); + assert!( + result.is_err(), + "clap must reject unknown --outer-synthesis values up-front" + ); + } + + // ------------------------------------------------------------------------- + // Spec P4: --allow-duplicates escape hatch + // ------------------------------------------------------------------------- + + #[test] + fn index_command_allow_duplicates_defaults_to_false() { + let cli = Cli::parse_from(["rust-memex", "index", "/tmp"]); + match cli.command { + Some(Commands::Index { + allow_duplicates, + dedup, + .. + }) => { + assert!( + !allow_duplicates, + "default must be false so the safe path (dedup-on) is the default" + ); + assert!( + dedup, + "dedup default must remain true; allow-duplicates is the explicit override" + ); + } + other => panic!("expected index command, got {:?}", other), + } + } + + #[test] + fn index_command_accepts_allow_duplicates_flag() { + let cli = Cli::parse_from(["rust-memex", "index", "/tmp", "--allow-duplicates"]); + match cli.command { + Some(Commands::Index { + allow_duplicates, + dedup, + .. + }) => { + assert!(allow_duplicates); + // The flag itself does not flip --dedup at parse time; the + // dispatcher applies the precedence at run time so the user + // sees a "Note: ..." breadcrumb when both flags are set. + assert!(dedup); + } + other => panic!("expected index command, got {:?}", other), + } + } + + #[test] + fn reprocess_command_accepts_allow_duplicates_flag() { + let cli = Cli::parse_from([ + "rust-memex", + "reprocess", + "-n", + "kb:rebuilt", + "-i", + "/tmp/export.jsonl", + "--allow-duplicates", + ]); + match cli.command { + Some(Commands::Reprocess { + allow_duplicates, .. + }) => { + assert!(allow_duplicates); + } + other => panic!("expected reprocess command, got {:?}", other), + } + } + + #[test] + fn reindex_command_accepts_allow_duplicates_flag() { + let cli = Cli::parse_from([ + "rust-memex", + "reindex", + "-n", + "kb:transcripts", + "--allow-duplicates", + ]); + match cli.command { + Some(Commands::Reindex { + allow_duplicates, .. + }) => { + assert!(allow_duplicates); + } + other => panic!("expected reindex command, got {:?}", other), + } + } + + // ------------------------------------------------------------------------- + // Spec memex-001: `migrate-schema` CLI surface + // ------------------------------------------------------------------------- + + #[test] + fn migrate_schema_command_defaults_to_v4_live_run() { + let cli = Cli::parse_from(["rust-memex", "migrate-schema"]); + match cli.command { + Some(Commands::MigrateSchema { target, check_only }) => { + assert_eq!(target, SchemaVersion::V4); + assert!(!check_only); + } + other => panic!("expected migrate-schema, got {:?}", other), + } + } + + #[test] + fn migrate_schema_command_accepts_check_only_and_target_alias() { + let cli = Cli::parse_from([ + "rust-memex", + "migrate-schema", + "--target", + "4", + "--check-only", + ]); + match cli.command { + Some(Commands::MigrateSchema { target, check_only }) => { + assert_eq!(target, SchemaVersion::V4); + assert!(check_only); + } + other => panic!("expected migrate-schema, got {:?}", other), + } + } + + #[test] + fn auto_migrate_defaults_off_and_is_global_for_daemon_modes() { + let cli = Cli::parse_from(["rust-memex", "sse"]); + assert!(!cli.auto_migrate); + + let cli = Cli::parse_from(["rust-memex", "--auto-migrate", "sse"]); + assert!(cli.auto_migrate); + + let cli = Cli::parse_from(["rust-memex", "dashboard", "--auto-migrate"]); + assert!(cli.auto_migrate); + } + + // ------------------------------------------------------------------------- + // Spec P0 backfill: `backfill-hashes` CLI surface + // ------------------------------------------------------------------------- + + #[test] + fn backfill_hashes_command_defaults_to_dry_run_all_namespaces() { + let cli = Cli::parse_from(["rust-memex", "backfill-hashes"]); + match cli.command { + Some(Commands::BackfillHashes { + namespace, + dry_run, + json, + .. + }) => { + assert!(namespace.is_none(), "no -n means all namespaces"); + assert!(dry_run, "default must be dry-run for safety"); + assert!(!json); + } + other => panic!("expected backfill-hashes, got {:?}", other), + } + } + + #[test] + fn backfill_hashes_command_accepts_namespace_and_live_run() { + let cli = Cli::parse_from([ + "rust-memex", + "backfill-hashes", + "-n", + "kb:transcripts", + "--dry-run", + "false", + "--json", + ]); + match cli.command { + Some(Commands::BackfillHashes { + namespace, + dry_run, + json, + .. + }) => { + assert_eq!(namespace.as_deref(), Some("kb:transcripts")); + assert!(!dry_run, "operator opted into a live write"); + assert!(json); + } + other => panic!("expected backfill-hashes, got {:?}", other), + } + } } diff --git a/src/bin/cli/dispatch.rs b/crates/rust-memex/src/bin/cli/dispatch.rs similarity index 57% rename from src/bin/cli/dispatch.rs rename to crates/rust-memex/src/bin/cli/dispatch.rs index 95c90ae..027c848 100644 --- a/src/bin/cli/dispatch.rs +++ b/crates/rust-memex/src/bin/cli/dispatch.rs @@ -9,7 +9,8 @@ use tracing_subscriber::FmtSubscriber; use rust_memex::{ EmbeddingClient, QueryRouter, RAGPipeline, SearchMode, SearchModeRecommendation, SliceLayer, - SliceMode, StorageManager, WizardConfig, create_server, run_wizard, + SliceMode, StorageManager, WizardConfig, create_server, default_reindexed_namespace, + diagnostics::KeepStrategy, run_wizard, }; use crate::cli::config::*; @@ -19,10 +20,26 @@ use crate::cli::inspection::*; use crate::cli::maintenance::*; use crate::cli::search::*; +/// Validate that an auth token contains only ASCII bytes (RFC 7230). +/// Returns Ok(()) if valid, Err with descriptive message if non-ASCII byte found. +fn validate_ascii_token(token: &str) -> Result<()> { + for (pos, byte) in token.bytes().enumerate() { + if !byte.is_ascii() { + return Err(anyhow::anyhow!( + "ERROR: --auth-token must be ASCII (RFC 7230). Got non-ASCII byte 0x{:02x} at position {}.", + byte, + pos + )); + } + } + Ok(()) +} + fn resolve_http_server_config( cli: &Cli, file_cfg: &FileConfig, -) -> rust_memex::http::HttpServerConfig { + port: u16, +) -> Result { let auth_token = cli .auth_token .clone() @@ -52,11 +69,89 @@ fn resolve_http_server_config( }) .unwrap_or_default(); - rust_memex::http::HttpServerConfig { + let auth_mode_str = file_cfg.auth_mode.as_deref().unwrap_or("mutating-only"); + // CLI flag overrides file config + let auth_mode = rust_memex::http::AuthMode::parse(if cli.auth_mode != "mutating-only" { + &cli.auth_mode + } else { + auth_mode_str + }); + let allow_query_token = cli.allow_query_token || file_cfg.allow_query_token.unwrap_or(false); + let env_oidc_issuer = std::env::var("MEMEX_DASHBOARD_OIDC_ISSUER_URL").ok(); + let env_oidc_client_id = std::env::var("MEMEX_DASHBOARD_OIDC_CLIENT_ID").ok(); + let env_oidc_client_secret = std::env::var("MEMEX_DASHBOARD_OIDC_CLIENT_SECRET").ok(); + let env_oidc_public_base_url = std::env::var("MEMEX_DASHBOARD_OIDC_PUBLIC_BASE_URL").ok(); + let env_oidc_scopes = std::env::var("MEMEX_DASHBOARD_OIDC_SCOPES").ok(); + + let dashboard_oidc = if env_oidc_issuer.is_some() || env_oidc_client_id.is_some() { + let issuer_url = env_oidc_issuer.ok_or_else(|| { + anyhow::anyhow!( + "MEMEX_DASHBOARD_OIDC_CLIENT_ID was provided without MEMEX_DASHBOARD_OIDC_ISSUER_URL" + ) + })?; + let client_id = env_oidc_client_id.ok_or_else(|| { + anyhow::anyhow!( + "MEMEX_DASHBOARD_OIDC_ISSUER_URL was provided without MEMEX_DASHBOARD_OIDC_CLIENT_ID" + ) + })?; + let scopes = env_oidc_scopes + .map(|value| { + value + .split(',') + .map(|scope| scope.trim().to_string()) + .filter(|scope| !scope.is_empty()) + .collect::>() + }) + .filter(|scopes| !scopes.is_empty()) + .unwrap_or_else(default_dashboard_oidc_scopes); + + Some(rust_memex::http::DashboardOidcConfig { + issuer_url, + client_id, + client_secret: env_oidc_client_secret, + public_base_url: env_oidc_public_base_url, + scopes, + server_port: port, + }) + } else { + file_cfg + .dashboard_oidc + .clone() + .map(|oidc| rust_memex::http::DashboardOidcConfig { + issuer_url: oidc.issuer_url, + client_id: oidc.client_id, + client_secret: oidc.client_secret, + public_base_url: oidc.public_base_url, + scopes: if oidc.scopes.is_empty() { + default_dashboard_oidc_scopes() + } else { + oidc.scopes + }, + server_port: port, + }) + }; + + let auth_mode = if dashboard_oidc.is_some() { + rust_memex::http::AuthMode::AllRoutes + } else { + auth_mode + }; + + if dashboard_oidc.is_some() && auth_token.is_none() { + return Err(anyhow::anyhow!( + "Dashboard OIDC requires --auth-token (or MEMEX_AUTH_TOKEN / auth_token in config) so API/MCP/SSE remain bearer-protected" + )); + } + + Ok(rust_memex::http::HttpServerConfig { auth_token, + dashboard_oidc, cors_origins, bind_address, - } + auth_mode, + allow_query_token, + auth_manager: None, // initialized lazily in start_server if NamespaceAcl mode + }) } fn dashboard_browser_url(bind_address: IpAddr, port: u16) -> String { @@ -71,16 +166,21 @@ fn dashboard_browser_url(bind_address: IpAddr, port: u16) -> String { } fn open_browser(url: &str) -> Result<()> { + // Each supported-platform branch returns Ok(()) directly; the fallback + // branch is compiled in only when none of the above targets match. This + // keeps clippy happy on every target (no unreachable_code, no + // needless_return) while still producing a real runtime error on + // unsupported platforms. #[cfg(target_os = "macos")] { ProcessCommand::new("open").arg(url).spawn()?; - return Ok(()); + Ok(()) } #[cfg(target_os = "linux")] { ProcessCommand::new("xdg-open").arg(url).spawn()?; - return Ok(()); + Ok(()) } #[cfg(target_os = "windows")] @@ -88,19 +188,58 @@ fn open_browser(url: &str) -> Result<()> { ProcessCommand::new("cmd") .args(["/C", "start", "", url]) .spawn()?; - return Ok(()); + Ok(()) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = url; + Err(anyhow::anyhow!( + "Automatic browser open is not supported on this platform" + )) + } +} + +/// Validate startup preconditions for the HTTP server: +/// - ASCII guard on auth token +/// - Non-loopback bind without auth is a hard error (unless escape hatch) +fn validate_http_preconditions( + http_config: &rust_memex::http::HttpServerConfig, + allow_network_without_auth: bool, +) -> Result<()> { + // ASCII guard + if let Some(ref token) = http_config.auth_token { + validate_ascii_token(token)?; + } + + // Bind guard: non-loopback without auth + if !http_config.bind_address.is_loopback() && http_config.auth_token.is_none() { + if allow_network_without_auth { + eprintln!( + "WARNING: HTTP server exposed on network without auth token. \ + This is allowed via --allow-network-without-auth but is NOT recommended." + ); + } else { + return Err(anyhow::anyhow!( + "ERROR: Refusing to bind to {} without --auth-token. \ + Network-exposed server without authentication is a security risk.\n\ + Options:\n \ + 1. Add --auth-token or set MEMEX_AUTH_TOKEN\n \ + 2. Add --allow-network-without-auth to override (not recommended)", + http_config.bind_address + )); + } } - #[allow(unreachable_code)] - Err(anyhow::anyhow!( - "Automatic browser open is not supported on this platform" - )) + Ok(()) } async fn run_http_only_command(cli: Cli, port: u16, auto_open_browser: bool) -> Result<()> { let (file_cfg, _) = load_or_discover_config(cli.config.as_deref())?; - let http_server_config = resolve_http_server_config(&cli, &file_cfg); + let http_server_config = resolve_http_server_config(&cli, &file_cfg, port)?; + validate_http_preconditions(&http_server_config, cli.allow_network_without_auth)?; let dashboard_url = dashboard_browser_url(http_server_config.bind_address, port); + let auto_migrate = cli.auto_migrate; let mut config = cli.into_server_config()?; config.hybrid.bm25.read_only = true; @@ -116,6 +255,8 @@ async fn run_http_only_command(cli: Cli, port: u16, auto_open_browser: bool) -> info!("Cache: {}MB", config.cache_mb); info!("DB Path: {}", config.db_path); + rust_memex::guard_daemon_startup_schema(&config.db_path, auto_migrate).await?; + let server = create_server(config).await?; let mcp_core = server.mcp_core(); @@ -150,6 +291,7 @@ pub async fn run_command(cli: Cli) -> Result<()> { } Some(Commands::Index { path, + source, namespace, recursive, glob, @@ -157,7 +299,15 @@ pub async fn run_command(cli: Cli) -> Result<()> { preprocess, sanitize_metadata, slice_mode, + chunker, + outer_synthesis, + ollama_model, + ollama_endpoint, dedup, + allow_duplicates, + strict, + max_failure_rate, + json, progress, resume, pipeline, @@ -165,6 +315,9 @@ pub async fn run_command(cli: Cli) -> Result<()> { pipeline_governor, parallel, }) => { + let path = path + .or(source) + .ok_or_else(|| anyhow::anyhow!("index requires PATH or --source "))?; let cfg = ResolvedConfig::load(cli.config.as_deref(), cli.db_path.as_deref())?; let _cache_mb = cli.cache_mb.or(cfg.file_cfg.cache_mb).unwrap_or(4096); let preprocess = preprocess || cfg.file_cfg.preprocessing_enabled.unwrap_or(false); @@ -174,6 +327,23 @@ pub async fn run_command(cli: Cli) -> Result<()> { slice_mode ) })?; + let outer_synthesis = + parse_outer_synthesis_flag(&outer_synthesis, &ollama_model, &ollama_endpoint)?; + // Spec P4 escape hatch: `--allow-duplicates` is the explicit knob for + // "force reindex." It supersedes `--dedup` so an operator never needs + // to pass both flags, and the run log records that dedup was disabled + // intentionally rather than because the user forgot the flag. + let dedup_effective = if allow_duplicates { + if dedup { + eprintln!( + "Note: --allow-duplicates is set, disabling --dedup for this run \ + (force reindex)." + ); + } + false + } else { + dedup + }; let result = run_batch_index(BatchIndexConfig { path, @@ -185,7 +355,12 @@ pub async fn run_command(cli: Cli) -> Result<()> { preprocess, sanitize_metadata, slice_mode, - dedup, + chunker, + outer_synthesis, + dedup: dedup_effective, + strict, + max_failure_rate, + json, embedding_config: cfg.embedding_config, show_progress: progress, resume, @@ -370,10 +545,11 @@ pub async fn run_command(cli: Cli) -> Result<()> { } let meta: serde_json::Value = serde_json::from_str(&metadata) .map_err(|e| anyhow::anyhow!("Invalid metadata JSON: {}", e))?; + let storage = Arc::new(StorageManager::new_lance_only(&cfg.db_path).await?); + storage.require_current_schema_for_writes().await?; let embedding_client = Arc::new(Mutex::new( EmbeddingClient::new(&cfg.embedding_config).await?, )); - let storage = Arc::new(StorageManager::new_lance_only(&cfg.db_path).await?); let rag = RAGPipeline::new(embedding_client, storage.clone()).await?; rag.memory_upsert(&namespace, id.clone(), content.clone(), meta) .await?; @@ -474,7 +650,7 @@ pub async fn run_command(cli: Cli) -> Result<()> { Some(Commands::Stats) => { let cfg = ResolvedConfig::load(cli.config.as_deref(), cli.db_path.as_deref())?; let storage = StorageManager::new_lance_only(&cfg.db_path).await?; - let stats = storage.stats().await?; + let stats = rust_memex::diagnostics::database_stats(&storage).await?; eprintln!("Database Statistics:"); eprintln!(" Table: {}", stats.table_name); eprintln!(" Path: {}", stats.db_path); @@ -551,14 +727,16 @@ pub async fn run_command(cli: Cli) -> Result<()> { dry_run, keep, cross_namespace, + group_by, json, }) => { let cfg = ResolvedConfig::load(cli.config.as_deref(), cli.db_path.as_deref())?; run_dedup( namespace, dry_run, - KeepStrategy::from_str(&keep), + KeepStrategy::from(keep.as_str()), cross_namespace, + rust_memex::diagnostics::DedupGroupBy::parse(&group_by), json, cfg.db_path, ) @@ -602,8 +780,13 @@ pub async fn run_command(cli: Cli) -> Result<()> { namespace, input, slice_mode, + chunker, preprocess, skip_existing, + allow_duplicates, + strict, + max_failure_rate, + json, dry_run, db_path: cmd_db_path, }) => { @@ -625,8 +808,13 @@ pub async fn run_command(cli: Cli) -> Result<()> { namespace, input, slice_mode, + chunker, preprocess, skip_existing, + allow_duplicates, + strict, + max_failure_rate, + json, dry_run, db_path, }, @@ -638,8 +826,13 @@ pub async fn run_command(cli: Cli) -> Result<()> { namespace, target_namespace, slice_mode, + chunker, preprocess, skip_existing, + allow_duplicates, + strict, + max_failure_rate, + json, dry_run, db_path: cmd_db_path, }) => { @@ -663,8 +856,13 @@ pub async fn run_command(cli: Cli) -> Result<()> { source_namespace: namespace, target_namespace, slice_mode, + chunker, preprocess, skip_existing, + allow_duplicates, + strict, + max_failure_rate, + json, dry_run, db_path, }, @@ -689,16 +887,64 @@ pub async fn run_command(cli: Cli) -> Result<()> { let cfg = ResolvedConfig::load(cli.config.as_deref(), cli.db_path.as_deref())?; run_purge_quality(threshold, confirm, json, cfg.db_path).await } + Some(Commands::MigrateSchema { target, check_only }) => { + let cfg = ResolvedConfig::load(cli.config.as_deref(), cli.db_path.as_deref())?; + run_migrate_schema(target, check_only, cfg.db_path).await + } + Some(Commands::BackfillHashes { + namespace, + dry_run, + json, + strict, + max_failure_rate, + }) => { + let cfg = ResolvedConfig::load(cli.config.as_deref(), cli.db_path.as_deref())?; + run_backfill_hashes( + namespace, + dry_run, + json, + strict, + max_failure_rate, + cfg.db_path, + ) + .await + } + Some(Commands::Auth { action }) => run_auth_command(action, cli.token_store_path).await, Some(Commands::Serve) | None => { let http_port = cli.http_port; let http_only = cli.http_only; + let auto_migrate = cli.auto_migrate; if http_only && http_port.is_none() { return Err(anyhow::anyhow!( "--http-only requires --http-port to be set" )); } let (file_cfg_ref, _) = load_or_discover_config(cli.config.as_deref())?; - let http_server_config = resolve_http_server_config(&cli, &file_cfg_ref); + // Validate HTTP preconditions (ASCII guard, bind guard) before starting + if http_port.is_some() || http_only { + let http_server_config = resolve_http_server_config( + &cli, + &file_cfg_ref, + http_port.unwrap_or(DEFAULT_SSE_PORT), + )?; + validate_http_preconditions(&http_server_config, cli.allow_network_without_auth)?; + } + let http_only_server_config = if http_only { + Some(resolve_http_server_config( + &cli, + &file_cfg_ref, + http_port.expect("validated above"), + )?) + } else { + None + }; + let sse_server_config = if !http_only { + http_port + .map(|port| resolve_http_server_config(&cli, &file_cfg_ref, port)) + .transpose()? + } else { + None + }; let mut config = cli.into_server_config()?; if http_only { config.hybrid.bm25.read_only = true; @@ -712,15 +958,20 @@ pub async fn run_command(cli: Cli) -> Result<()> { info!("Starting RMCP Memex"); info!("Cache: {}MB", config.cache_mb); info!("DB Path: {}", config.db_path); + if http_port.is_some() || http_only { + rust_memex::guard_daemon_startup_schema(&config.db_path, auto_migrate).await?; + } let server = create_server(config).await?; if http_only { let port = http_port.expect("validated above"); + let http_server_config = http_only_server_config.expect("prepared above"); let mcp_core = server.mcp_core(); info!("Starting HTTP-only server on port {} (no MCP stdio)", port); rust_memex::http::start_server(mcp_core, port, http_server_config).await?; return Ok(()); } if let Some(port) = http_port { + let http_server_config = sse_server_config.expect("prepared above"); let mcp_core = server.mcp_core(); info!("Starting HTTP/SSE server on port {}", port); tokio::spawn(async move { @@ -735,3 +986,246 @@ pub async fn run_command(cli: Cli) -> Result<()> { } } } + +async fn run_auth_command(action: AuthAction, token_store_path: Option) -> Result<()> { + use rust_memex::auth::{Scope, TokenStoreFile}; + use std::str::FromStr; + + let store_path = + token_store_path.unwrap_or_else(|| "~/.rmcp-servers/rust-memex/tokens.json".to_string()); + let store = TokenStoreFile::new(store_path.clone()); + store.load().await?; + + match action { + AuthAction::Create { + id, + description, + scopes, + namespaces, + expires_at, + json, + } => { + let token_id = id.unwrap_or_else(|| { + use uuid::Uuid; + Uuid::new_v4().to_string()[..8].to_string() + }); + + let parsed_scopes: Vec = scopes + .split(',') + .map(|s| Scope::from_str(s.trim())) + .collect::>>()?; + + let parsed_namespaces: Vec = namespaces + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + let parsed_expiry = if let Some(exp_str) = expires_at { + Some( + chrono::DateTime::parse_from_rfc3339(&exp_str) + .map_err(|e| anyhow::anyhow!("Invalid expiry format: {}. Use RFC 3339 (e.g., 2026-12-31T00:00:00Z)", e))? + .with_timezone(&chrono::Utc), + ) + } else { + None + }; + + let plaintext = store + .create_token( + token_id.clone(), + parsed_scopes.clone(), + parsed_namespaces.clone(), + parsed_expiry, + description.clone(), + ) + .await?; + + if json { + let output = serde_json::json!({ + "id": token_id, + "token": plaintext, + "scopes": parsed_scopes, + "namespaces": parsed_namespaces, + "description": description, + "store_path": store_path, + "warning": "This token is shown ONCE. Store it securely." + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + eprintln!("Token created successfully."); + eprintln!(); + eprintln!(" ID: {}", token_id); + eprintln!(" Scopes: {}", scopes); + eprintln!(" Namespaces: {}", namespaces); + eprintln!(" Description: {}", description); + eprintln!(" Store: {}", store_path); + eprintln!(); + eprintln!(" TOKEN (shown ONCE, store securely):"); + println!("{}", plaintext); + eprintln!(); + eprintln!(" Use as: Authorization: Bearer {}", plaintext); + } + + Ok(()) + } + AuthAction::List { json } => { + let tokens = store.list_tokens().await; + + if json { + let output: Vec = tokens + .iter() + .map(|t| { + serde_json::json!({ + "id": t.id, + "scopes": t.scopes, + "namespaces": t.namespaces, + "expires_at": t.expires_at, + "description": t.description, + "created_at": t.created_at, + "expired": t.is_expired(), + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&output)?); + } else if tokens.is_empty() { + eprintln!("No tokens configured."); + eprintln!("Create one with: rust-memex auth create --description \"My token\""); + } else { + eprintln!("{} token(s) in store:", tokens.len()); + eprintln!(); + for t in &tokens { + let scopes_str: Vec = t.scopes.iter().map(|s| s.to_string()).collect(); + let expired_marker = if t.is_expired() { " [EXPIRED]" } else { "" }; + eprintln!(" {} {}", t.id, expired_marker); + eprintln!(" Description: {}", t.description); + eprintln!(" Scopes: [{}]", scopes_str.join(", ")); + eprintln!(" Namespaces: [{}]", t.namespaces.join(", ")); + if let Some(exp) = t.expires_at { + eprintln!(" Expires: {}", exp.to_rfc3339()); + } else { + eprintln!(" Expires: never"); + } + eprintln!(" Created: {}", t.created_at.to_rfc3339()); + eprintln!(); + } + } + + Ok(()) + } + AuthAction::Revoke { id } => { + let removed = store.revoke_token(&id).await?; + if removed { + eprintln!("Token '{}' revoked.", id); + } else { + eprintln!("Token '{}' not found.", id); + } + Ok(()) + } + AuthAction::Rotate { id, json } => { + let new_plaintext = store.rotate_token(&id).await?; + + if json { + let output = serde_json::json!({ + "id": id, + "token": new_plaintext, + "warning": "This token is shown ONCE. Store it securely." + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + eprintln!("Token '{}' rotated.", id); + eprintln!(); + eprintln!(" NEW TOKEN (shown ONCE, store securely):"); + println!("{}", new_plaintext); + eprintln!(); + eprintln!(" Use as: Authorization: Bearer {}", new_plaintext); + } + + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + #[test] + fn ascii_guard_accepts_ascii_token() { + assert!(validate_ascii_token("my-secure-token-123").is_ok()); + assert!(validate_ascii_token("abcABC012!@#$%").is_ok()); + assert!(validate_ascii_token("").is_ok()); // empty is technically ASCII + } + + #[test] + fn ascii_guard_rejects_non_ascii_token() { + let err = validate_ascii_token("token-with-\u{015b}-polish").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("ASCII"), + "Error message should mention ASCII: {msg}" + ); + assert!( + msg.contains("0xc5"), + "Error message should show the offending byte: {msg}" + ); + } + + #[test] + fn bind_guard_blocks_network_without_auth() { + let config = rust_memex::http::HttpServerConfig { + bind_address: Ipv4Addr::UNSPECIFIED.into(), + auth_token: None, + ..Default::default() + }; + let result = validate_http_preconditions(&config, false); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("Refusing to bind"), + "Should contain refusal: {msg}" + ); + } + + #[test] + fn bind_guard_allows_network_with_escape_hatch() { + let config = rust_memex::http::HttpServerConfig { + bind_address: Ipv4Addr::UNSPECIFIED.into(), + auth_token: None, + ..Default::default() + }; + assert!(validate_http_preconditions(&config, true).is_ok()); + } + + #[test] + fn bind_guard_allows_network_with_auth() { + let config = rust_memex::http::HttpServerConfig { + bind_address: Ipv4Addr::UNSPECIFIED.into(), + auth_token: Some("my-token".to_string()), + ..Default::default() + }; + assert!(validate_http_preconditions(&config, false).is_ok()); + } + + #[test] + fn bind_guard_allows_localhost_without_auth() { + let config = rust_memex::http::HttpServerConfig { + bind_address: Ipv4Addr::LOCALHOST.into(), + auth_token: None, + ..Default::default() + }; + assert!(validate_http_preconditions(&config, false).is_ok()); + } + + #[test] + fn ascii_guard_rejects_non_ascii_in_preconditions() { + let config = rust_memex::http::HttpServerConfig { + auth_token: Some("token-\u{0107}".to_string()), // \u{0107} = 'c' with acute + ..Default::default() + }; + let result = validate_http_preconditions(&config, false); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("ASCII")); + } +} diff --git a/src/bin/cli/formatting.rs b/crates/rust-memex/src/bin/cli/formatting.rs similarity index 74% rename from src/bin/cli/formatting.rs rename to crates/rust-memex/src/bin/cli/formatting.rs index 42715c7..21f873e 100644 --- a/src/bin/cli/formatting.rs +++ b/crates/rust-memex/src/bin/cli/formatting.rs @@ -1,71 +1,4 @@ -use anyhow::Result; - -use rust_memex::{HybridSearchResult, SearchMode, SliceLayer, path_utils}; - -#[allow(dead_code)] -fn parse_features(raw: &str) -> Vec { - raw.split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect() -} - -/// Standard config discovery locations (in priority order) -#[allow(dead_code)] -const CONFIG_SEARCH_PATHS: &[&str] = &[ - "~/.rmcp-servers/rust-memex/config.toml", - "~/.config/rust-memex/config.toml", - "~/.rmcp_servers/rust_memex/config.toml", // legacy underscore path -]; - -/// Discover config file from standard locations -#[allow(dead_code)] -fn discover_config() -> Option { - // 1. Environment variable takes priority - if let Ok(path) = std::env::var("RUST_MEMEX_CONFIG") { - let expanded = shellexpand::tilde(&path).to_string(); - if std::path::Path::new(&expanded).exists() { - return Some(path); - } - } - - // 2. Check standard locations - for path in CONFIG_SEARCH_PATHS { - let expanded = shellexpand::tilde(path).to_string(); - if std::path::Path::new(&expanded).exists() { - return Some(path.to_string()); - } - } - - None -} - -#[allow(dead_code)] -fn load_file_config(path: &str) -> Result { - let (_canonical, contents) = path_utils::safe_read_to_string(path) - .map_err(|e| anyhow::anyhow!("Cannot load config '{}': {}", path, e))?; - toml::from_str(&contents).map_err(Into::into) -} - -/// Load config from explicit path or discover from standard locations -#[allow(dead_code)] -fn load_or_discover_config(explicit_path: Option<&str>) -> Result<(FileConfig, Option)> { - // Explicit path takes priority - if let Some(path) = explicit_path { - return Ok((load_file_config(path)?, Some(path.to_string()))); - } - - // Try to discover config - if let Some(discovered) = discover_config() { - return Ok((load_file_config(&discovered)?, Some(discovered))); - } - - // No config found - use defaults - Ok((FileConfig::default(), None)) -} - -use crate::cli::config::*; +use rust_memex::{HybridSearchResult, SearchMode, SliceLayer}; /// Format and display search results (human-readable) pub fn display_search_results( query: &str, diff --git a/src/bin/cli/inspection.rs b/crates/rust-memex/src/bin/cli/inspection.rs similarity index 72% rename from src/bin/cli/inspection.rs rename to crates/rust-memex/src/bin/cli/inspection.rs index 0b96ac2..dfaf697 100644 --- a/src/bin/cli/inspection.rs +++ b/crates/rust-memex/src/bin/cli/inspection.rs @@ -3,85 +3,23 @@ use serde::Serialize; use std::sync::Arc; use tokio::sync::Mutex; +pub use rust_memex::contracts::stats::{DatabaseStats, NamespaceStats, StorageMetrics}; +pub use rust_memex::contracts::timeline::{TimeRange, TimelineEntry, TimelineFilter}; use rust_memex::{ - BM25Config, BM25Index, EmbeddingClient, EmbeddingConfig, HealthChecker, RAGPipeline, - SliceLayer, StorageManager, inspect_cross_store_recovery, path_utils, + BM25Config, BM25Index, EmbeddingClient, EmbeddingConfig, HealthChecker, HybridConfig, + HybridSearcher, RAGPipeline, SearchMode, SearchOptions, SliceLayer, StorageManager, + diagnostics::{ + self, TimelineBucket, TimelineQuery, namespace_stats as collect_namespace_stats, + }, + inspect_cross_store_recovery, }; -#[allow(dead_code)] -fn parse_features(raw: &str) -> Vec { - raw.split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect() -} - -/// Standard config discovery locations (in priority order) -#[allow(dead_code)] -const CONFIG_SEARCH_PATHS: &[&str] = &[ - "~/.rmcp-servers/rust-memex/config.toml", - "~/.config/rust-memex/config.toml", - "~/.rmcp_servers/rust_memex/config.toml", // legacy underscore path -]; - -/// Discover config file from standard locations -#[allow(dead_code)] -fn discover_config() -> Option { - // 1. Environment variable takes priority - if let Ok(path) = std::env::var("RUST_MEMEX_CONFIG") { - let expanded = shellexpand::tilde(&path).to_string(); - if std::path::Path::new(&expanded).exists() { - return Some(path); - } - } - - // 2. Check standard locations - for path in CONFIG_SEARCH_PATHS { - let expanded = shellexpand::tilde(path).to_string(); - if std::path::Path::new(&expanded).exists() { - return Some(path.to_string()); - } - } - - None -} - -#[allow(dead_code)] -fn load_file_config(path: &str) -> Result { - let (_canonical, contents) = path_utils::safe_read_to_string(path) - .map_err(|e| anyhow::anyhow!("Cannot load config '{}': {}", path, e))?; - toml::from_str(&contents).map_err(Into::into) -} - -/// Load config from explicit path or discover from standard locations -#[allow(dead_code)] -fn load_or_discover_config(explicit_path: Option<&str>) -> Result<(FileConfig, Option)> { - // Explicit path takes priority - if let Some(path) = explicit_path { - return Ok((load_file_config(path)?, Some(path.to_string()))); - } - - // Try to discover config - if let Some(discovered) = discover_config() { - return Ok((load_file_config(&discovered)?, Some(discovered))); - } - - // No config found - use defaults - Ok((FileConfig::default(), None)) -} - -use crate::cli::config::*; -/// Namespace overview stats -#[derive(Debug, Clone, serde::Serialize)] -pub struct NamespaceStats { - pub name: String, - pub total_chunks: usize, - pub layer_counts: std::collections::HashMap, - top_keywords: Vec<(String, usize)>, - pub has_timestamps: bool, - pub earliest_indexed: Option, - pub latest_indexed: Option, +fn bm25_path_from_db(db_path: &str) -> String { + let expanded = shellexpand::tilde(db_path).to_string(); + std::path::Path::new(&expanded) + .parent() + .map(|parent| parent.join("bm25").to_string_lossy().to_string()) + .unwrap_or_else(|| BM25Config::default().index_path) } /// Run overview command - quick stats and health check @@ -118,81 +56,7 @@ pub async fn run_overview( return Ok(()); } - // Group by namespace - let mut by_namespace: std::collections::HashMap> = - std::collections::HashMap::new(); - for doc in &all_docs { - by_namespace - .entry(doc.namespace.clone()) - .or_default() - .push(doc); - } - - let mut stats_list: Vec = Vec::new(); - - for (ns_name, docs) in &by_namespace { - // Count layers - let mut layer_counts: std::collections::HashMap = - std::collections::HashMap::new(); - for doc in docs { - let layer_name = match doc.layer { - 1 => "outer", - 2 => "middle", - 3 => "inner", - 4 => "core", - _ => "flat", - }; - *layer_counts.entry(layer_name.to_string()).or_insert(0) += 1; - } - - // Collect all keywords and count frequency - let mut keyword_counts: std::collections::HashMap = - std::collections::HashMap::new(); - for doc in docs { - for kw in &doc.keywords { - *keyword_counts.entry(kw.clone()).or_insert(0) += 1; - } - } - let mut top_keywords: Vec<_> = keyword_counts.into_iter().collect(); - top_keywords.sort_by(|a, b| b.1.cmp(&a.1)); - let top_keywords: Vec<(String, usize)> = top_keywords.into_iter().take(10).collect(); - - // Check for timestamps in metadata (look for common timestamp patterns) - let has_timestamps = docs.iter().any(|d| { - let meta_str = d.metadata.to_string(); - meta_str.contains("timestamp") - || meta_str.contains("created_at") - || meta_str.contains("indexed_at") - || meta_str.contains("date") - }); - - // Try to extract date range from metadata - let mut dates: Vec = Vec::new(); - for doc in docs { - if let Some(obj) = doc.metadata.as_object() { - for (k, v) in obj { - if (k.contains("date") || k.contains("timestamp") || k.contains("time")) - && let Some(s) = v.as_str() - { - dates.push(s.to_string()); - } - } - } - } - dates.sort(); - - stats_list.push(NamespaceStats { - name: ns_name.clone(), - total_chunks: docs.len(), - layer_counts, - top_keywords, - has_timestamps, - earliest_indexed: dates.first().cloned(), - latest_indexed: dates.last().cloned(), - }); - } - - stats_list.sort_by(|a, b| a.name.cmp(&b.name)); + let stats_list = collect_namespace_stats(storage.as_ref(), namespace.as_deref()).await?; if json_output { let json = serde_json::json!({ @@ -203,7 +67,7 @@ pub async fn run_overview( }); println!("{}", serde_json::to_string_pretty(&json)?); } else { - eprintln!("\n=== RUST-MEMEX OVERVIEW ===\n"); + eprintln!("\n=== RMCP-MEMEX OVERVIEW ===\n"); eprintln!("Database: {}", db_path); eprintln!("Total chunks: {}", all_docs.len()); eprintln!("Namespaces: {}\n", stats_list.len()); @@ -614,7 +478,20 @@ pub async fn run_recall( storage.ensure_collection().await?; let embedding_client = Arc::new(Mutex::new(EmbeddingClient::new(embedding_config).await?)); - let rag = RAGPipeline::new(embedding_client, storage.clone()).await?; + let hybrid = HybridSearcher::new( + storage.clone(), + HybridConfig { + mode: SearchMode::Hybrid, + bm25: BM25Config { + index_path: bm25_path_from_db(&db_path), + read_only: true, + ..Default::default() + }, + ..Default::default() + }, + ) + .await?; + let query_embedding = embedding_client.lock().await.embed(&query).await?; // Get namespaces to search let namespaces: Vec = if let Some(ref ns) = namespace_filter { @@ -643,17 +520,38 @@ pub async fn run_recall( return Ok(()); } - // Search each namespace for outer layer results (summaries) + // Recall is a human-facing truth surface, not a token-minimization primitive. + // Search all onion layers and let hybrid lexical/vector ranking decide what is + // actually responsive to the query; outer-only recall often returns metadata + // stubs while the useful content lives in middle/core slices. let mut all_results: Vec<(String, rust_memex::SearchResult)> = Vec::new(); for ns in &namespaces { - // Search specifically for outer layer (summaries) - let results = rag - .memory_search_with_layer(ns, &query, limit, Some(SliceLayer::Outer)) + let results = hybrid + .search( + &query, + query_embedding.clone(), + Some(ns), + limit, + SearchOptions::deep(), + ) .await?; for r in results { - all_results.push((ns.clone(), r)); + all_results.push(( + ns.clone(), + rust_memex::SearchResult { + id: r.id, + namespace: r.namespace, + text: r.document, + score: r.combined_score, + metadata: r.metadata, + layer: r.layer, + parent_id: r.parent_id, + children_ids: r.children_ids, + keywords: r.keywords, + }, + )); } } @@ -818,32 +716,6 @@ pub async fn run_recall( Ok(()) } -/// Timeline entry for JSON output -#[derive(Debug, Clone, Serialize)] -pub struct TimelineEntry { - pub date: String, - pub namespace: String, - pub source: Option, - pub chunk_count: usize, -} - -/// Timeline report for JSON output -#[derive(Debug, Clone, Serialize)] -pub struct TimelineReport { - pub namespaces: Vec, - pub entries: Vec, - pub coverage: TimelineCoverage, - pub gaps: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct TimelineCoverage { - pub earliest: Option, - pub latest: Option, - pub total_days: usize, - pub days_with_data: usize, -} - /// Run timeline command - show when content was indexed pub async fn run_timeline( db_path: String, @@ -852,23 +724,19 @@ pub async fn run_timeline( show_gaps_only: bool, json_output: bool, ) -> Result<()> { - use std::collections::{BTreeMap, BTreeSet}; - let storage = StorageManager::new_lance_only(&db_path).await?; + let report = diagnostics::timeline_report( + &storage, + &TimelineQuery { + namespace: namespace_filter.clone(), + since, + until: None, + bucket: TimelineBucket::Day, + }, + ) + .await?; - // Get namespaces to query - let namespaces: Vec = if let Some(ref ns) = namespace_filter { - vec![ns.clone()] - } else { - storage - .list_namespaces() - .await? - .into_iter() - .map(|(name, _)| name) - .collect() - }; - - if namespaces.is_empty() { + if report.namespaces.is_empty() { if json_output { println!( "{}", @@ -884,199 +752,50 @@ pub async fn run_timeline( return Ok(()); } - // Parse since filter - let since_date: Option = since.as_ref().and_then(|s| { - // Try parsing as duration like "30d" - if let Some(days_str) = s.strip_suffix('d') - && let Ok(days) = days_str.parse::() - { - return Some((chrono::Utc::now() - chrono::Duration::days(days)).date_naive()); - } - // Try parsing as YYYY-MM - if s.len() == 7 - && s.chars().nth(4) == Some('-') - && let Ok(date) = chrono::NaiveDate::parse_from_str(&format!("{}-01", s), "%Y-%m-%d") - { - return Some(date); - } - // Try parsing as full date - chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok() - }); - - // Collect timeline data: date -> namespace -> source -> count - let mut timeline: BTreeMap>> = BTreeMap::new(); - let mut all_dates: BTreeSet = BTreeSet::new(); - - for ns_name in &namespaces { - let docs = storage.get_all_in_namespace(ns_name).await?; - - for doc in docs { - // Extract indexed_at from metadata - let indexed_at = doc - .metadata - .get("indexed_at") - .and_then(|v| v.as_str()) - .or_else(|| doc.metadata.get("timestamp").and_then(|v| v.as_str())); - - let date_str = if let Some(ts) = indexed_at { - // Parse ISO timestamp and extract date - ts.split('T').next().unwrap_or("unknown").to_string() - } else { - "unknown".to_string() - }; - - // Apply since filter - if let Some(since_d) = since_date - && let Ok(doc_date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") - && doc_date < since_d - { - continue; - } - - all_dates.insert(date_str.clone()); - - // Extract source from metadata - let source = doc - .metadata - .get("source") - .and_then(|v| v.as_str()) - .or_else(|| doc.metadata.get("file_path").and_then(|v| v.as_str())) - .map(|s| { - // Extract filename from path - std::path::Path::new(s) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(s) - .to_string() - }) - .unwrap_or_else(|| "unknown".to_string()); - - *timeline - .entry(date_str) - .or_default() - .entry(ns_name.clone()) - .or_default() - .entry(source) - .or_default() += 1; - } - } - - // Build entries list for JSON - let mut entries: Vec = Vec::new(); - for (date, ns_map) in &timeline { - for (ns, source_map) in ns_map { - for (source, count) in source_map { - entries.push(TimelineEntry { - date: date.clone(), - namespace: ns.clone(), - source: Some(source.clone()), - chunk_count: *count, - }); - } - } - } - - // Calculate coverage - let dates_vec: Vec<&String> = all_dates.iter().collect(); - let earliest = dates_vec.first().map(|s| (*s).clone()); - let latest = dates_vec.last().map(|s| (*s).clone()); - - // Find gaps (consecutive dates with no data) - let mut gaps: Vec = Vec::new(); - if dates_vec.len() >= 2 { - let sorted_dates: Vec = dates_vec - .iter() - .filter_map(|s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) - .collect(); - - for window in sorted_dates.windows(2) { - let diff = (window[1] - window[0]).num_days(); - if diff > 1 { - gaps.push(format!( - "{} to {} ({} days)", - window[0].format("%Y-%m-%d"), - window[1].format("%Y-%m-%d"), - diff - 1 - )); - } - } - } - - let coverage = TimelineCoverage { - earliest, - latest, - total_days: if dates_vec.len() >= 2 { - dates_vec - .first() - .and_then(|e| chrono::NaiveDate::parse_from_str(e, "%Y-%m-%d").ok()) - .zip( - dates_vec - .last() - .and_then(|l| chrono::NaiveDate::parse_from_str(l, "%Y-%m-%d").ok()), - ) - .map(|(e, l)| (l - e).num_days() as usize + 1) - .unwrap_or(0) - } else { - dates_vec.len() - }, - days_with_data: all_dates.len(), - }; - - let report = TimelineReport { - namespaces: namespaces.clone(), - entries, - coverage, - gaps: gaps.clone(), - }; - // Output if json_output { println!("{}", serde_json::to_string_pretty(&report)?); } else if show_gaps_only { // Only show gaps - if gaps.is_empty() { + if report.gaps.is_empty() { eprintln!("No gaps found in timeline"); } else { eprintln!("Timeline Gaps:"); - for gap in &gaps { + for gap in &report.gaps { eprintln!(" - {}", gap); } } } else { // Full timeline grouped by month - eprintln!("Timeline: {} namespace(s)", namespaces.len()); + eprintln!("Timeline: {} namespace(s)", report.namespaces.len()); if let Some(ref ns) = namespace_filter { eprintln!(" Namespace: {}", ns); } eprintln!(); // Group by year-month - let mut by_month: BTreeMap> = BTreeMap::new(); - for (date, ns_map) in &timeline { - let month = if date.len() >= 7 { - date[..7].to_string() + let mut by_month: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for entry in &report.entries { + let month = if entry.date.len() >= 7 { + entry.date[..7].to_string() } else { - date.clone() + entry.date.clone() }; - for (ns, source_map) in ns_map { - let total: usize = source_map.values().sum(); - let sources: Vec<_> = source_map.keys().take(3).cloned().collect(); - let source_str = if sources.len() < source_map.len() { - format!( - "{} (+{} more)", - sources.join(", "), - source_map.len() - sources.len() - ) - } else { - sources.join(", ") - }; - by_month.entry(month.clone()).or_default().push(( - date.clone(), - format!("[{}] {} ({})", ns, source_str, total), - total, - )); - } + by_month.entry(month).or_default().push(( + entry.date.clone(), + format!( + "[{}] {} ({})", + entry.namespace, + entry + .source + .clone() + .unwrap_or_else(|| "unknown".to_string()), + entry.chunk_count + ), + entry.chunk_count, + )); } for (month, entries) in by_month { @@ -1100,14 +819,14 @@ pub async fn run_timeline( report.coverage.days_with_data, report.coverage.total_days ); - if !gaps.is_empty() { + if !report.gaps.is_empty() { eprintln!(); - eprintln!("Gaps ({}):", gaps.len()); - for gap in gaps.iter().take(5) { + eprintln!("Gaps ({}):", report.gaps.len()); + for gap in report.gaps.iter().take(5) { eprintln!(" - {}", gap); } - if gaps.len() > 5 { - eprintln!(" ... and {} more gaps", gaps.len() - 5); + if report.gaps.len() > 5 { + eprintln!(" ... and {} more gaps", report.gaps.len() - 5); } } } diff --git a/src/bin/cli/maintenance.rs b/crates/rust-memex/src/bin/cli/maintenance.rs similarity index 71% rename from src/bin/cli/maintenance.rs rename to crates/rust-memex/src/bin/cli/maintenance.rs index 908eea1..028b370 100644 --- a/src/bin/cli/maintenance.rs +++ b/crates/rust-memex/src/bin/cli/maintenance.rs @@ -9,82 +9,22 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::{Duration, Instant}; use tokio::sync::{Mutex, Semaphore, mpsc}; +pub use rust_memex::diagnostics::{DedupGroup, DedupResult, KeepStrategy}; use rust_memex::{ - BM25Config, BM25Index, CrossStoreRecoveryReport, EmbeddingClient, EmbeddingConfig, - IndexProgressTracker, PipelineConfig, PipelineEvent, PipelineSnapshot, PreprocessingConfig, - RAGPipeline, SliceMode, StorageManager, inspect_cross_store_recovery, path_utils, - rag::PipelineGovernorConfig, repair_cross_store_recovery, + ChunkerKind, CrossStoreRecoveryReport, EmbeddingClient, EmbeddingConfig, IndexProgressTracker, + OuterSynthesis, PipelineConfig, PipelineEvent, PipelineSnapshot, PreprocessingConfig, + RAGPipeline, SliceMode, StorageManager, detect_default_chunker, diagnostics, merge_databases, + migrate_namespace_atomic, rag::PipelineGovernorConfig, repair_writes as execute_repair_writes, }; -#[allow(dead_code)] -fn parse_features(raw: &str) -> Vec { - raw.split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect() -} - -/// Standard config discovery locations (in priority order) -#[allow(dead_code)] -const CONFIG_SEARCH_PATHS: &[&str] = &[ - "~/.rmcp-servers/rust-memex/config.toml", - "~/.config/rust-memex/config.toml", - "~/.rmcp_servers/rust_memex/config.toml", // legacy underscore path -]; - -/// Discover config file from standard locations -#[allow(dead_code)] -fn discover_config() -> Option { - // 1. Environment variable takes priority - if let Ok(path) = std::env::var("RUST_MEMEX_CONFIG") { - let expanded = shellexpand::tilde(&path).to_string(); - if std::path::Path::new(&expanded).exists() { - return Some(path); - } - } - - // 2. Check standard locations - for path in CONFIG_SEARCH_PATHS { - let expanded = shellexpand::tilde(path).to_string(); - if std::path::Path::new(&expanded).exists() { - return Some(path.to_string()); - } - } - - None -} - -#[allow(dead_code)] -fn load_file_config(path: &str) -> Result { - let (_canonical, contents) = path_utils::safe_read_to_string(path) - .map_err(|e| anyhow::anyhow!("Cannot load config '{}': {}", path, e))?; - toml::from_str(&contents).map_err(Into::into) -} - -/// Load config from explicit path or discover from standard locations -#[allow(dead_code)] -fn load_or_discover_config(explicit_path: Option<&str>) -> Result<(FileConfig, Option)> { - // Explicit path takes priority - if let Some(path) = explicit_path { - return Ok((load_file_config(path)?, Some(path.to_string()))); - } - - // Try to discover config - if let Some(discovered) = discover_config() { - return Ok((load_file_config(&discovered)?, Some(discovered))); - } - - // No config found - use defaults - Ok((FileConfig::default(), None)) -} - -use crate::cli::config::*; +use crate::cli::batch_policy::{BatchFailurePolicy, BatchRunSummary}; use crate::cli::definition::*; #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct IndexCheckpointStats { pub total_files: usize, + pub discovered_files: usize, + pub resumed_files: usize, pub files_read: usize, pub files_skipped: usize, pub files_committed: usize, @@ -99,6 +39,8 @@ impl From<&PipelineSnapshot> for IndexCheckpointStats { fn from(snapshot: &PipelineSnapshot) -> Self { Self { total_files: snapshot.total_files, + discovered_files: snapshot.discovered_files, + resumed_files: snapshot.resumed_files, files_read: snapshot.files_read, files_skipped: snapshot.files_skipped, files_committed: snapshot.files_committed, @@ -210,7 +152,18 @@ pub struct BatchIndexConfig { /// Sanitize timestamps/UUIDs/session IDs (default: false = preserve for temporal queries) pub sanitize_metadata: bool, pub slice_mode: SliceMode, + pub chunker: Option, + /// Outer-layer synthesis strategy for onion modes (spec P3). + /// + /// `OuterSynthesis::Keyword` keeps the legacy TF-based path; `Llm` routes + /// the outer through a local Ollama model. The non-pipeline path always + /// uses `Keyword` regardless — see `run_batch_index` for the up-front + /// guard that rejects `Llm + !pipeline` instead of silently downgrading. + pub outer_synthesis: OuterSynthesis, pub dedup: bool, + pub strict: bool, + pub max_failure_rate: f64, + pub json: bool, pub embedding_config: EmbeddingConfig, /// Show progress bar with calibration-based ETA pub show_progress: bool, @@ -226,6 +179,49 @@ pub struct BatchIndexConfig { pub parallel: u8, } +/// Translate the CLI string flags `--outer-synthesis` / `--ollama-model` / +/// `--ollama-endpoint` into a typed [`OuterSynthesis`] value. +/// +/// Returns `OuterSynthesis::Keyword` for `"keyword"` (regardless of the model / +/// endpoint values, which are simply ignored when irrelevant) and +/// `OuterSynthesis::Llm { model, endpoint }` for `"llm"`. Any unknown variant +/// raises an error so the operator sees the lie loud, never silent. +/// +/// Empty model / endpoint strings are rejected at this layer so the lib API +/// (which would silently fall back to keyword on a malformed Ollama call) is +/// not asked to do operator-input validation. +pub fn parse_outer_synthesis_flag( + variant: &str, + ollama_model: &str, + ollama_endpoint: &str, +) -> Result { + match variant { + "keyword" => Ok(OuterSynthesis::Keyword), + "llm" => { + let model = ollama_model.trim(); + let endpoint = ollama_endpoint.trim(); + if model.is_empty() { + anyhow::bail!( + "--outer-synthesis llm requires a non-empty --ollama-model (got empty string)" + ); + } + if endpoint.is_empty() { + anyhow::bail!( + "--outer-synthesis llm requires a non-empty --ollama-endpoint (got empty string)" + ); + } + Ok(OuterSynthesis::Llm { + model: model.to_string(), + endpoint: endpoint.to_string(), + }) + } + other => anyhow::bail!( + "Invalid --outer-synthesis '{}'. Use one of: keyword, llm", + other + ), + } +} + /// Result of indexing a single file (for parallel processing) #[derive(Debug)] pub enum FileIndexResult { @@ -236,7 +232,7 @@ pub enum FileIndexResult { /// File was skipped (already in checkpoint) SkippedResume, /// Indexing failed - Failed, + Failed { path: String, error: String }, } fn pipeline_embed_rate(snapshot: &PipelineSnapshot) -> f64 { @@ -248,8 +244,15 @@ fn pipeline_embed_rate(snapshot: &PipelineSnapshot) -> f64 { } } -fn format_pipeline_status_line(snapshot: &PipelineSnapshot, total_files: usize) -> String { +fn format_pipeline_status_line(snapshot: &PipelineSnapshot, scheduled_files: usize) -> String { let terminal_files = snapshot.files_committed + snapshot.files_skipped + snapshot.files_failed; + let discovered_files = snapshot + .discovered_files + .max(scheduled_files.saturating_add(snapshot.resumed_files)) + .max(scheduled_files); + let completed_discovered = terminal_files + .saturating_add(snapshot.resumed_files) + .min(discovered_files); let eta = snapshot .eta .map(HumanDuration) @@ -261,9 +264,11 @@ fn format_pipeline_status_line(snapshot: &PipelineSnapshot, total_files: usize) .unwrap_or_else(|| "--".to_string()); format!( - "{}/{} files | read {} | committed {} skipped {} failed {} | chunks created {} embedded {} ({:.1}/s) stored {} ({:.1}/s) | eta {} | q {}/{}/{} | embed {}/{} req @ {} | batch {}/{} items {} / {} chars | gov {} ({}) | {}", - terminal_files.min(total_files), - total_files, + "{}/{} discovered | scheduled {} resumed {} | read {} | committed {} skipped {} failed {} | chunks created {} embedded {} ({:.1}/s) stored {} ({:.1}/s) | eta {} | q {}/{}/{} | embed {}/{} req @ {} | batch {}/{} items {} / {} chars | gov {} ({}) | {}", + completed_discovered, + discovered_files, + scheduled_files, + snapshot.resumed_files, snapshot.files_read, snapshot.files_committed, snapshot.files_skipped, @@ -302,6 +307,10 @@ fn format_pipeline_error_line(path: Option<&Path>, stage: &str, message: &str) - } } +fn should_disable_pipeline_storage_dedup(existing_checkpoint_loaded: bool) -> bool { + existing_checkpoint_loaded +} + struct PipelineProgressRenderer { total_files: usize, progress_bar: Option, @@ -341,13 +350,20 @@ impl PipelineProgressRenderer { fn render(&mut self, snapshot: &PipelineSnapshot, force: bool) { let terminal_files = snapshot.files_committed + snapshot.files_skipped + snapshot.files_failed; + let discovered_files = snapshot + .discovered_files + .max(self.total_files.saturating_add(snapshot.resumed_files)) + .max(self.total_files); + let completed_discovered = terminal_files + .saturating_add(snapshot.resumed_files) + .min(discovered_files); let message = format_pipeline_status_line(snapshot, self.total_files); if let Some(progress_bar) = &self.progress_bar { - progress_bar.set_length(self.total_files as u64); - progress_bar.set_position(terminal_files.min(self.total_files) as u64); + progress_bar.set_length(discovered_files as u64); + progress_bar.set_position(completed_discovered as u64); progress_bar.set_message(message.clone()); - if force && terminal_files >= self.total_files { + if force && completed_discovered >= discovered_files { progress_bar.finish_with_message(format!("complete | {}", message)); } return; @@ -362,18 +378,36 @@ impl PipelineProgressRenderer { } } -async fn consume_pipeline_events( - mut rx: mpsc::UnboundedReceiver, +struct PipelineEventConsumerConfig { checkpoint: Option>>, db_path: String, show_progress: bool, interactive_progress: bool, - total_files: usize, + scheduled_files: usize, + discovered_files: usize, + resumed_files: usize, +} + +async fn consume_pipeline_events( + mut rx: mpsc::UnboundedReceiver, + config: PipelineEventConsumerConfig, ) -> PipelineSnapshot { + let PipelineEventConsumerConfig { + checkpoint, + db_path, + show_progress, + interactive_progress, + scheduled_files, + discovered_files, + resumed_files, + } = config; + let mut renderer = - show_progress.then(|| PipelineProgressRenderer::new(total_files, interactive_progress)); + show_progress.then(|| PipelineProgressRenderer::new(scheduled_files, interactive_progress)); let mut latest_snapshot = PipelineSnapshot { - total_files, + total_files: scheduled_files, + discovered_files, + resumed_files, ..Default::default() }; @@ -398,7 +432,7 @@ async fn consume_pipeline_events( } } PipelineEvent::Snapshot(snapshot) => { - latest_snapshot = snapshot; + latest_snapshot = *snapshot; if let Some(checkpoint) = &checkpoint { let mut checkpoint = checkpoint.lock().await; checkpoint.update_from_snapshot(&latest_snapshot); @@ -453,7 +487,12 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { preprocess, sanitize_metadata, slice_mode, + chunker, + outer_synthesis, dedup, + strict, + max_failure_rate, + json, embedding_config, show_progress, resume, @@ -462,6 +501,31 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { pipeline_governor, parallel, } = config; + let failure_policy = BatchFailurePolicy::new(strict, max_failure_rate)?; + + // Spec P3: only the --pipeline path threads `OuterSynthesis` through to the + // async slicers. The legacy non-pipeline path uses `OnionSliceConfig::default()` + // and would silently produce keyword outers regardless of this flag. Reject the + // combination up-front so an operator never thinks they ran an LLM rebuild + // when the keyword path actually shipped. + if matches!(outer_synthesis, OuterSynthesis::Llm { .. }) && !pipeline { + anyhow::bail!( + "--outer-synthesis llm requires --pipeline mode. The legacy non-pipeline path \ + does not invoke the async slicer that drives Ollama, so without --pipeline \ + every document would silently fall back to the keyword outer. Re-run with \ + --pipeline (and --pipeline-governor for adaptive flow control)." + ); + } + if matches!(outer_synthesis, OuterSynthesis::Llm { .. }) + && !matches!(slice_mode, SliceMode::Onion | SliceMode::OnionFast) + { + anyhow::bail!( + "--outer-synthesis llm only applies to onion slice modes (got --slice-mode {:?}). \ + The flat slicer has no outer layer to synthesize.", + slice_mode + ); + } + // Expand and canonicalize path - canonicalize validates path exists and resolves symlinks let expanded = shellexpand::tilde(path.to_str().unwrap_or("")).to_string(); let canonical = Path::new(&expanded).canonicalize()?; @@ -472,6 +536,9 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { if total == 0 { eprintln!("No files found matching criteria"); + if json { + BatchRunSummary::new(0, 0, 0, Vec::new()).emit_json()?; + } return Ok(()); } @@ -480,6 +547,9 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { SliceMode::OnionFast => "onion-fast (outer+core, 2 layers)", SliceMode::Flat => "flat (traditional chunks)", }; + let chunker_name = chunker + .map(|kind| kind.name().to_string()) + .unwrap_or_else(|| "auto".to_string()); let use_progress_bar = show_progress && std::io::stderr().is_terminal(); if show_progress && !use_progress_bar { @@ -491,7 +561,10 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { t.display_pre_scan(); Some(t) } else { - eprintln!("Found {} files to index (slice mode: {})", total, mode_name); + eprintln!( + "Found {} files to index (slice mode: {}, chunker: {})", + total, mode_name, chunker_name + ); if preprocess { eprintln!("Preprocessing enabled: filtering tool artifacts, CLI output, and metadata"); } @@ -509,31 +582,34 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { } // Use full storage for CLI batch indexing to ensure BM25 is written - let embedding_client = Arc::new(Mutex::new(EmbeddingClient::new(&embedding_config).await?)); let storage = Arc::new(StorageManager::new(&expanded_db).await?); + storage.require_current_schema_for_writes().await?; + let embedding_client = Arc::new(Mutex::new(EmbeddingClient::new(&embedding_config).await?)); let ns_name = namespace.as_deref().unwrap_or("rag"); // Pipeline mode: concurrent stages with channels if pipeline { - if preprocess { - eprintln!("Warning: --preprocess is not supported in pipeline mode (ignoring)"); - } - - let checkpoint = if resume { + let (checkpoint, existing_checkpoint_loaded) = if resume { if let Some(cp) = IndexCheckpoint::load(&db_path, ns_name) { let resumed_count = cp.indexed_files.len(); eprintln!( "Resuming from checkpoint: {} files already committed", resumed_count ); - Arc::new(Mutex::new(cp)) + (Arc::new(Mutex::new(cp)), true) } else { - Arc::new(Mutex::new(IndexCheckpoint::new(ns_name, &db_path))) + ( + Arc::new(Mutex::new(IndexCheckpoint::new(ns_name, &db_path))), + false, + ) } } else { IndexCheckpoint::delete(&db_path, ns_name); - Arc::new(Mutex::new(IndexCheckpoint::new(ns_name, &db_path))) + ( + Arc::new(Mutex::new(IndexCheckpoint::new(ns_name, &db_path))), + false, + ) }; let (pipeline_files, resumed_count, disable_storage_dedup) = if resume { @@ -544,7 +620,8 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { .filter(|path| !checkpoint_guard.is_indexed(path)) .cloned() .collect(); - let disable_storage_dedup = resumed_count > 0; + let disable_storage_dedup = + should_disable_pipeline_storage_dedup(existing_checkpoint_loaded); (filtered_files, resumed_count, disable_storage_dedup) } else { (files.clone(), 0, false) @@ -555,6 +632,9 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { if resume { IndexCheckpoint::delete(&db_path, ns_name); } + if json { + BatchRunSummary::new(0, 0, total, Vec::new()).emit_json()?; + } return Ok(()); } @@ -582,16 +662,26 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { let (event_tx, event_rx) = mpsc::unbounded_channel(); let event_task = tokio::spawn(consume_pipeline_events( event_rx, - resume.then_some(Arc::clone(&checkpoint)), - db_path.clone(), - show_progress, - use_progress_bar, - pipeline_files.len(), + PipelineEventConsumerConfig { + checkpoint: resume.then_some(Arc::clone(&checkpoint)), + db_path: db_path.clone(), + show_progress, + interactive_progress: use_progress_bar, + scheduled_files: pipeline_files.len(), + discovered_files: total, + resumed_files: resumed_count, + }, )); let pipeline_config = PipelineConfig { slice_mode, + chunker, + outer_synthesis: outer_synthesis.clone(), dedup_enabled: dedup && !disable_storage_dedup, + preprocess_config: preprocess.then_some(PreprocessingConfig { + remove_metadata: sanitize_metadata, + ..Default::default() + }), embed_concurrency: pipeline_embed_concurrency as usize, governor: pipeline_governor.then(|| { PipelineGovernorConfig::adaptive( @@ -601,9 +691,21 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { ) }), event_sender: Some(event_tx), + discovered_files: total, + resumed_files: resumed_count, ..Default::default() }; + // Operator-visible breadcrumb so the run log records which outer the + // P3 spec procedure actually got. The flag is silent on the keyword + // path (legacy behavior) and loud on the LLM path. + if let OuterSynthesis::Llm { model, endpoint } = &outer_synthesis { + eprintln!( + "Outer synthesis: LLM via Ollama (model={}, endpoint={})", + model, endpoint + ); + } + let pipeline_run = rust_memex::run_pipeline( pipeline_files, ns_name.to_string(), @@ -667,6 +769,22 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { ); } + let summary = BatchRunSummary::new( + result.stats.files_committed, + result.stats.files_failed, + total, + result.errors.iter().map(ToString::to_string).collect(), + ); + if json { + summary.emit_json()?; + } else if summary.failed > 0 { + eprintln!( + "WARNING: {}/{} files failed to index. See log above.", + summary.failed, summary.total + ); + } + summary.enforce(failure_policy, "files")?; + return Ok(()); } @@ -747,7 +865,7 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { let ns = namespace.clone(); let canonical = canonical.clone(); let embedder_model = embedder_model.clone(); - let _ns_name = ns_name.to_string(); + let ns_name = ns_name.to_string(); let handle = tokio::spawn(async move { // Acquire semaphore permit to limit concurrency @@ -801,8 +919,16 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { ) .await } else { - rag.index_document_with_dedup(&file_path, ns.as_deref(), effective_mode) - .await + let selected_chunker = + chunker.unwrap_or_else(|| detect_default_chunker(&file_path, &ns_name)); + rag.index_document_with_chunker( + &file_path, + ns.as_deref(), + selected_chunker, + effective_mode, + true, + ) + .await } } else { // Use original indexing without dedup (convert to IndexResult-like outcome) @@ -816,46 +942,78 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { .map(|()| rust_memex::IndexResult::Indexed { chunks_indexed: (file_bytes as usize / 500).max(1), content_hash: String::new(), + embedder_ms: None, + tokens_estimated: None, }) } else { - rag.index_document_with_mode(&file_path, ns.as_deref(), effective_mode) - .await - .map(|()| rust_memex::IndexResult::Indexed { - chunks_indexed: (file_bytes as usize / 500).max(1), - content_hash: String::new(), - }) + let selected_chunker = + chunker.unwrap_or_else(|| detect_default_chunker(&file_path, &ns_name)); + rag.index_document_with_chunker( + &file_path, + ns.as_deref(), + selected_chunker, + effective_mode, + false, + ) + .await } }; let file_result = match result { Ok(rust_memex::IndexResult::Indexed { chunks_indexed, .. }) => { - // Handle calibration on first completed file - if !calibration_done.swap(true, Ordering::SeqCst) - && let Some(ref t) = tracker - { - let mut guard = t.lock().await; - guard.finish_calibration(chunks_indexed, &embedder_model); - guard.adjust_estimate(file_bytes, chunks_indexed); - guard.start_progress_bar(); - } - - indexed_count.fetch_add(1, Ordering::SeqCst); - total_chunks_count.fetch_add(chunks_indexed, Ordering::SeqCst); - - if let Some(ref t) = tracker { - t.lock().await.file_indexed(chunks_indexed); + if chunks_indexed == 0 { + // A zero-chunk "success" means the file did not actually land in memory. + // Treat it as a per-file failure so strict/threshold policies can catch it. + if !calibration_done.swap(true, Ordering::SeqCst) + && let Some(ref t) = tracker + { + let mut guard = t.lock().await; + guard.finish_calibration(0, &embedder_model); + guard.start_progress_bar(); + } + + let error = "no chunks indexed".to_string(); + failed_count.fetch_add(1, Ordering::SeqCst); + + if let Some(ref t) = tracker { + t.lock().await.file_failed(); + } else { + eprintln!(" -> {} FAILED: {}", display_path, error); + } + + FileIndexResult::Failed { + path: display_path, + error, + } } else { - eprintln!(" -> {} done ({} chunks)", display_path, chunks_indexed); + // Handle calibration on first completed file + if !calibration_done.swap(true, Ordering::SeqCst) + && let Some(ref t) = tracker + { + let mut guard = t.lock().await; + guard.finish_calibration(chunks_indexed, &embedder_model); + guard.adjust_estimate(file_bytes, chunks_indexed); + guard.start_progress_bar(); + } + + indexed_count.fetch_add(1, Ordering::SeqCst); + total_chunks_count.fetch_add(chunks_indexed, Ordering::SeqCst); + + if let Some(ref t) = tracker { + t.lock().await.file_indexed(chunks_indexed); + } else { + eprintln!(" -> {} done ({} chunks)", display_path, chunks_indexed); + } + + // Update checkpoint + if resume { + let mut cp = checkpoint.lock().await; + cp.mark_indexed(&file_path); + let _ = cp.save(&db_path); + } + + FileIndexResult::Indexed } - - // Update checkpoint - if resume { - let mut cp = checkpoint.lock().await; - cp.mark_indexed(&file_path); - let _ = cp.save(&db_path); - } - - FileIndexResult::Indexed } Ok(rust_memex::IndexResult::Skipped { reason, .. }) => { // Handle calibration if this was the first file @@ -902,7 +1060,10 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { eprintln!(" -> {} FAILED: {}", display_path, e); } - FileIndexResult::Failed + FileIndexResult::Failed { + path: display_path, + error: e.to_string(), + } } }; @@ -922,6 +1083,10 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { // Task panicked - count as failure failed_count.fetch_add(1, Ordering::SeqCst); eprintln!("Task panicked: {}", e); + results.push(FileIndexResult::Failed { + path: "".to_string(), + error: e.to_string(), + }); } } } @@ -1007,6 +1172,24 @@ pub async fn run_batch_index(config: BatchIndexConfig) -> Result<()> { ); } + let errors = results + .iter() + .filter_map(|result| match result { + FileIndexResult::Failed { path, error } => Some(format!("{path}: {error}")), + _ => None, + }) + .collect(); + let summary = BatchRunSummary::new(indexed, failed, total, errors); + if json { + summary.emit_json()?; + } else if summary.failed > 0 { + eprintln!( + "WARNING: {}/{} files failed to index. See log above.", + summary.failed, summary.total + ); + } + summary.enforce(failure_policy, "files")?; + Ok(()) } @@ -1018,6 +1201,8 @@ mod tests { fn format_pipeline_status_line_surfaces_stage_flow() { let snapshot = PipelineSnapshot { total_files: 6, + discovered_files: 8, + resumed_files: 2, files_read: 4, files_committed: 2, files_skipped: 1, @@ -1047,7 +1232,8 @@ mod tests { let line = format_pipeline_status_line(&snapshot, 6); - assert!(line.contains("4/6 files")); + assert!(line.contains("6/8 discovered")); + assert!(line.contains("scheduled 6 resumed 2")); assert!(line.contains("read 4")); assert!(line.contains("embedded 20 (4.0/s)")); assert!(line.contains("stored 18 (3.6/s)")); @@ -1071,6 +1257,138 @@ mod tests { "[pipeline:error] embedder [/tmp/corpus/a.md] connection reset" ); } + + #[test] + fn resume_checkpoint_disables_pipeline_storage_dedup_even_without_committed_files() { + assert!(should_disable_pipeline_storage_dedup(true)); + assert!(!should_disable_pipeline_storage_dedup(false)); + } + + #[test] + fn parse_outer_synthesis_flag_keyword_ignores_ollama_overrides() { + let parsed = parse_outer_synthesis_flag("keyword", "ignored", "ignored").unwrap(); + match parsed { + OuterSynthesis::Keyword => {} + other => panic!("expected Keyword, got {:?}", other), + } + } + + #[test] + fn parse_outer_synthesis_flag_llm_carries_model_and_endpoint() { + let parsed = + parse_outer_synthesis_flag("llm", "qwen2.5:3b", "http://10.0.0.5:11434").unwrap(); + match parsed { + OuterSynthesis::Llm { model, endpoint } => { + assert_eq!(model, "qwen2.5:3b"); + assert_eq!(endpoint, "http://10.0.0.5:11434"); + } + other => panic!("expected Llm, got {:?}", other), + } + } + + #[test] + fn parse_outer_synthesis_flag_rejects_empty_model_or_endpoint() { + assert!( + parse_outer_synthesis_flag("llm", "", "http://localhost:11434").is_err(), + "empty model must error so an Ollama call never silently goes out with no model" + ); + assert!( + parse_outer_synthesis_flag("llm", "qwen2.5:3b", "").is_err(), + "empty endpoint must error so the helper does not fall back to a stale default" + ); + } + + #[test] + fn parse_outer_synthesis_flag_rejects_unknown_variant() { + let err = parse_outer_synthesis_flag("transformers", "model", "endpoint") + .expect_err("unknown variant must error"); + assert!( + err.to_string().contains("Invalid --outer-synthesis"), + "error must name the offending flag, got: {}", + err + ); + } + + fn make_index_config_for_guard( + pipeline: bool, + outer_synthesis: OuterSynthesis, + slice_mode: SliceMode, + ) -> BatchIndexConfig { + BatchIndexConfig { + // The guard runs before canonicalization, so the path being + // missing is fine — we never reach disk I/O on the error paths. + path: PathBuf::from("/dev/null/this-path-must-not-exist"), + namespace: Some("test-ns".to_string()), + recursive: false, + glob_pattern: None, + max_depth: 0, + db_path: "/tmp/rust-memex-test".to_string(), + preprocess: false, + sanitize_metadata: false, + slice_mode, + chunker: None, + outer_synthesis, + dedup: false, + strict: false, + max_failure_rate: 1.0, + json: false, + embedding_config: EmbeddingConfig::default(), + show_progress: false, + resume: false, + pipeline, + pipeline_embed_concurrency: 1, + pipeline_governor: false, + parallel: 1, + } + } + + #[tokio::test] + async fn run_batch_index_rejects_llm_without_pipeline_so_no_silent_keyword_downgrade() { + let config = make_index_config_for_guard( + false, + OuterSynthesis::Llm { + model: "qwen2.5:3b".to_string(), + endpoint: "http://localhost:11434".to_string(), + }, + SliceMode::Onion, + ); + let err = run_batch_index(config) + .await + .expect_err("LLM outer without --pipeline must error"); + let msg = err.to_string(); + assert!( + msg.contains("--outer-synthesis llm requires --pipeline mode"), + "guard message must point operator at --pipeline, got: {}", + msg + ); + assert!( + msg.contains("keyword outer"), + "guard message must explain the silent-fallback risk so the operator \ + understands WHY this is rejected, got: {}", + msg + ); + } + + #[tokio::test] + async fn run_batch_index_rejects_llm_with_flat_slice_mode() { + let config = make_index_config_for_guard( + true, + OuterSynthesis::Llm { + model: "qwen2.5:3b".to_string(), + endpoint: "http://localhost:11434".to_string(), + }, + SliceMode::Flat, + ); + let err = run_batch_index(config) + .await + .expect_err("LLM outer with --slice-mode flat must error"); + let msg = err.to_string(); + assert!( + msg.contains("only applies to onion slice modes"), + "guard message must explain that flat has no outer layer, got: {}", + msg + ); + } } fn print_cross_store_recovery_report(report: &CrossStoreRecoveryReport, execute: bool) { @@ -1131,14 +1449,8 @@ pub async fn run_repair_writes( execute: bool, json_output: bool, ) -> Result<()> { - let storage = StorageManager::new_lance_only(&db_path).await?; - let bm25 = BM25Index::new(&BM25Config::default().with_read_only(!execute))?; - - let report = if execute { - repair_cross_store_recovery(&storage, &bm25, namespace.as_deref()).await? - } else { - inspect_cross_store_recovery(&storage, &bm25, namespace.as_deref()).await? - }; + let repair = execute_repair_writes(&db_path, namespace.as_deref(), execute).await?; + let report = repair.report; if json_output { println!("{}", serde_json::to_string_pretty(&report)?); @@ -1149,70 +1461,28 @@ pub async fn run_repair_writes( Ok(()) } -/// Strategy for keeping documents when deduplicating -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum KeepStrategy { - /// Keep the document with the earliest ID (lexicographic) - Oldest, - /// Keep the document with the latest ID (lexicographic) - Newest, - /// Keep the document that appears first in vector search (highest relevance) - HighestScore, -} - -impl KeepStrategy { - #[allow(clippy::should_implement_trait)] - pub fn from_str(s: &str) -> Self { - match s { - "newest" => Self::Newest, - "highest-score" => Self::HighestScore, - _ => Self::Oldest, - } - } -} - -/// Result of deduplication operation -#[derive(Debug, Clone, Serialize)] -pub struct DedupResult { - /// Total documents scanned - pub total_docs: usize, - /// Documents with unique content (no duplicates) - pub unique_docs: usize, - /// Duplicate groups found (each group has 2+ docs with same hash) - pub duplicate_groups: usize, - /// Total duplicate documents that would be/were removed - pub duplicates_removed: usize, - /// Documents without content_hash (cannot be deduplicated) - pub docs_without_hash: usize, - /// Details of each duplicate group (for reporting) - pub groups: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct DedupGroup { - pub content_hash: String, - pub kept_id: String, - pub kept_namespace: String, - removed_ids: Vec<(String, String)>, // (id, namespace) -} - /// Run deduplication on the database pub async fn run_dedup( namespace: Option, dry_run: bool, keep_strategy: KeepStrategy, cross_namespace: bool, + group_by: rust_memex::diagnostics::DedupGroupBy, json_output: bool, db_path: String, ) -> Result<()> { let storage = Arc::new(StorageManager::new_lance_only(&db_path).await?); + let result = diagnostics::deduplicate_documents( + storage.as_ref(), + namespace.as_deref(), + dry_run, + keep_strategy, + cross_namespace, + group_by, + ) + .await?; - // Get all documents (optionally filtered by namespace) - let all_docs = storage - .all_documents(namespace.as_deref(), 1_000_000) - .await?; - - if all_docs.is_empty() { + if result.total_docs == 0 { if json_output { println!( "{}", @@ -1229,98 +1499,20 @@ pub async fn run_dedup( } if !json_output { - eprintln!("Scanning {} documents for duplicates...", all_docs.len()); + eprintln!("Scanning {} documents for duplicates...", result.total_docs); + eprintln!(" Group by: {}", group_by.label()); if dry_run { eprintln!("(dry-run mode: no changes will be made)"); } } - // Group documents by content_hash - // If cross_namespace is false, we group by (namespace, content_hash) - // If cross_namespace is true, we group by content_hash only - let mut hash_groups: std::collections::HashMap> = - std::collections::HashMap::new(); - let mut docs_without_hash = 0; - - for doc in &all_docs { - match &doc.content_hash { - Some(hash) if !hash.is_empty() => { - let key = if cross_namespace { - hash.clone() - } else { - format!("{}:{}", doc.namespace, hash) - }; - hash_groups.entry(key).or_default().push(doc); - } - _ => { - docs_without_hash += 1; - } - } - } - - // Find groups with duplicates (more than 1 document per hash) - let mut result = DedupResult { - total_docs: all_docs.len(), - unique_docs: 0, - duplicate_groups: 0, - duplicates_removed: 0, - docs_without_hash, - groups: Vec::new(), - }; - - for (_key, mut docs) in hash_groups { - if docs.len() == 1 { - result.unique_docs += 1; - continue; - } - - // Sort documents based on keep strategy - match keep_strategy { - KeepStrategy::Oldest => { - docs.sort_by(|a, b| a.id.cmp(&b.id)); - } - KeepStrategy::Newest => { - docs.sort_by(|a, b| b.id.cmp(&a.id)); - } - KeepStrategy::HighestScore => { - // Already in search order (highest score first), no sort needed - } - } - - // First document is kept, rest are duplicates - let kept = &docs[0]; - let to_remove: Vec<_> = docs[1..].to_vec(); - - let group = DedupGroup { - content_hash: kept.content_hash.clone().unwrap_or_default(), - kept_id: kept.id.clone(), - kept_namespace: kept.namespace.clone(), - removed_ids: to_remove - .iter() - .map(|d| (d.id.clone(), d.namespace.clone())) - .collect(), - }; - - result.duplicate_groups += 1; - result.duplicates_removed += to_remove.len(); - result.unique_docs += 1; // The kept one is unique - - // Actually delete if not dry-run - if !dry_run { - for doc in &to_remove { - storage.delete_document(&doc.namespace, &doc.id).await?; - } - } - - result.groups.push(group); - } - // Output results if json_output { let output = serde_json::json!({ "dry_run": dry_run, "namespace": namespace, "cross_namespace": cross_namespace, + "group_by": group_by.label(), "keep_strategy": format!("{:?}", keep_strategy).to_lowercase(), "result": result, }); @@ -1341,7 +1533,7 @@ pub async fn run_dedup( ); if result.docs_without_hash > 0 { eprintln!( - " Without hash: {} (cannot deduplicate)", + " Without group key: {} (cannot deduplicate; backfill --group-by source-hash needs source_hash)", result.docs_without_hash ); } @@ -1357,17 +1549,20 @@ pub async fn run_dedup( ); for group in result.groups.iter().take(show_count) { eprintln!(); - eprintln!( - " Hash: {}...", - &group.content_hash[..group.content_hash.len().min(16)] - ); + let key_display = if group.group_key.is_empty() { + &group.content_hash + } else { + &group.group_key + }; + let max = key_display.len().min(48); + eprintln!(" Key: {}...", &key_display[..max]); eprintln!(" Kept: {} (ns: {})", group.kept_id, group.kept_namespace); - for (id, ns) in &group.removed_ids { + for removed in &group.removed { eprintln!( " {} {} (ns: {})", if dry_run { "Would remove:" } else { "Removed:" }, - id, - ns + removed.id, + removed.namespace ); } } @@ -1539,6 +1734,37 @@ pub async fn run_migrate_namespace( return Ok(()); } + if !merge && delete_source { + let outcome = migrate_namespace_atomic(&storage, &from, &to).await?; + let result = MigrationResult { + from_namespace: outcome.from_namespace.clone(), + to_namespace: outcome.to_namespace.clone(), + docs_migrated: outcome.migrated_chunks, + docs_merged: 0, + source_deleted: true, + dry_run: false, + }; + + if json_output { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "success", + "result": result + }))? + ); + } else { + eprintln!("\n-> Namespace Migration Complete\n"); + eprintln!(" From: '{}'", outcome.from_namespace); + eprintln!(" To: '{}'", outcome.to_namespace); + eprintln!(" Docs migrated: {}", outcome.migrated_chunks); + eprintln!(" Source '{}': atomically renamed", outcome.from_namespace); + eprintln!("\n DB path: {}", db_path); + } + + return Ok(()); + } + // Perform the migration // Create new documents with updated namespace let migrated_docs: Vec = source_docs @@ -1713,34 +1939,26 @@ pub async fn run_merge( json_output: bool, ) -> Result<()> { let mut stats = MergeStats::default(); - - // Validate and sanitize source paths (prevents path traversal) - let mut validated_sources: Vec = Vec::new(); - for source in &source_paths { - let source_str = source.to_str().unwrap_or(""); - match path_utils::sanitize_existing_path(source_str) { - Ok(validated) => validated_sources.push(validated), - Err(e) => { - if !json_output { - eprintln!("Warning: Source database invalid: {} - {}", source_str, e); - } - stats.errors += 1; - } - } - } - - if validated_sources.is_empty() { - return Err(anyhow::anyhow!("No valid source databases found")); - } - - // Validate and sanitize target path (prevents path traversal) - let target_str = target_path.to_str().unwrap_or(""); - let validated_target = path_utils::sanitize_new_path(target_str)?; + let execution = merge_databases( + source_paths.clone(), + target_path, + dedup, + namespace_prefix.clone(), + dry_run, + ) + .await?; + let validated_target = execution.target_path; + stats.total_docs = execution.progress.total_docs; + stats.docs_copied = execution.progress.docs_copied; + stats.docs_skipped = execution.progress.docs_skipped; + stats.namespaces = execution.progress.namespaces.into_iter().collect(); + stats.sources_processed = execution.progress.sources_processed; + stats.errors = execution.progress.errors; if !json_output { - eprintln!("\n=== RUST-MEMEX MERGE ===\n"); - eprintln!("Sources: {} database(s)", validated_sources.len()); - for src in &validated_sources { + eprintln!("\n=== RMCP-MEMEX MERGE ===\n"); + eprintln!("Sources: {} database(s)", source_paths.len()); + for src in &source_paths { eprintln!(" - {}", src.display()); } eprintln!("Target: {}", validated_target.display()); @@ -1754,184 +1972,6 @@ pub async fn run_merge( eprintln!(); } - // Open target storage (will create if not exists) - let target_storage = if !dry_run { - // Ensure parent directory exists for target - if let Some(parent) = validated_target.parent() { - std::fs::create_dir_all(parent)?; - } - Some(StorageManager::new_lance_only(validated_target.to_str().unwrap_or("")).await?) - } else { - None - }; - - // Track content hashes for deduplication (across all sources) - let mut seen_hashes: HashSet = HashSet::new(); - - // If dedup is enabled and target exists, pre-populate seen_hashes from target - if dedup - && !dry_run - && let Some(ref target) = target_storage - { - // Get all existing documents from target to extract their hashes - if let Ok(existing_docs) = target.all_documents(None, 100000).await { - for doc in existing_docs { - if let Some(hash) = doc.content_hash { - seen_hashes.insert(hash); - } - } - if !json_output && !seen_hashes.is_empty() { - eprintln!( - "Found {} existing documents in target for dedup\n", - seen_hashes.len() - ); - } - } - } - - // Process each source database - for source_path in &validated_sources { - if !json_output { - eprintln!("Processing: {}", source_path.display()); - } - - // Open source database read-only - // SAFETY: source_path was validated by path_utils::sanitize_existing_path above - let source_path_str = source_path.to_str().unwrap_or(""); - let source_storage = match StorageManager::new_lance_only(source_path_str).await { - Ok(s) => s, - Err(e) => { - if !json_output { - eprintln!(" Error opening source: {}", e); - } - stats.errors += 1; - continue; - } - }; - - // Get all documents from source (using zero embedding for full scan) - let source_docs = match source_storage.all_documents(None, 100000).await { - Ok(docs) => docs, - Err(e) => { - if !json_output { - eprintln!(" Error reading source: {}", e); - } - stats.errors += 1; - continue; - } - }; - - if source_docs.is_empty() { - if !json_output { - eprintln!(" (empty database)\n"); - } - stats.sources_processed += 1; - continue; - } - - let source_doc_count = source_docs.len(); - stats.total_docs += source_doc_count; - - // Group by namespace for reporting - let mut by_namespace: std::collections::HashMap> = - std::collections::HashMap::new(); - for doc in source_docs { - by_namespace - .entry(doc.namespace.clone()) - .or_default() - .push(doc); - } - - if !json_output { - eprintln!( - " Found {} documents in {} namespace(s)", - source_doc_count, - by_namespace.len() - ); - } - - // Process each namespace - for (ns_name, docs) in by_namespace { - // Apply namespace prefix if specified - let target_namespace = if let Some(ref prefix) = namespace_prefix { - format!("{}{}", prefix, ns_name) - } else { - ns_name.clone() - }; - - stats.namespaces.insert(target_namespace.clone()); - - let mut ns_copied = 0; - let mut ns_skipped = 0; - - // Prepare batch for insertion - let mut batch: Vec = Vec::new(); - - for doc in docs { - // Check for deduplication - if dedup && let Some(ref hash) = doc.content_hash { - if seen_hashes.contains(hash) { - ns_skipped += 1; - stats.docs_skipped += 1; - continue; - } - seen_hashes.insert(hash.clone()); - } - - // Create document with new namespace - let new_doc = rust_memex::ChromaDocument { - id: doc.id, - namespace: target_namespace.clone(), - embedding: doc.embedding, - metadata: doc.metadata, - document: doc.document, - layer: doc.layer, - parent_id: doc.parent_id, - children_ids: doc.children_ids, - keywords: doc.keywords, - content_hash: doc.content_hash, - }; - - batch.push(new_doc); - ns_copied += 1; - stats.docs_copied += 1; - } - - // Write batch to target (unless dry run) - if !dry_run - && !batch.is_empty() - && let Some(ref target) = target_storage - && let Err(e) = target.add_to_store(batch).await - { - if !json_output { - eprintln!(" Error writing to target: {}", e); - } - stats.errors += 1; - } - - if !json_output { - let prefix_info = if namespace_prefix.is_some() { - format!(" -> {}", target_namespace) - } else { - String::new() - }; - if ns_skipped > 0 { - eprintln!( - " [{}{}] {} copied, {} skipped (duplicate)", - ns_name, prefix_info, ns_copied, ns_skipped - ); - } else { - eprintln!(" [{}{}] {} copied", ns_name, prefix_info, ns_copied); - } - } - } - - stats.sources_processed += 1; - if !json_output { - eprintln!(); - } - } - // Output final summary if json_output { let output = serde_json::json!({ diff --git a/src/bin/cli/mod.rs b/crates/rust-memex/src/bin/cli/mod.rs similarity index 86% rename from src/bin/cli/mod.rs rename to crates/rust-memex/src/bin/cli/mod.rs index e5052c6..de11f07 100644 --- a/src/bin/cli/mod.rs +++ b/crates/rust-memex/src/bin/cli/mod.rs @@ -1,3 +1,4 @@ +pub mod batch_policy; pub mod config; pub mod data; pub mod definition; diff --git a/src/bin/cli/search.rs b/crates/rust-memex/src/bin/cli/search.rs similarity index 88% rename from src/bin/cli/search.rs rename to crates/rust-memex/src/bin/cli/search.rs index 2710bad..7bf4a80 100644 --- a/src/bin/cli/search.rs +++ b/crates/rust-memex/src/bin/cli/search.rs @@ -6,74 +6,21 @@ use tokio::sync::Mutex; use rust_memex::{ BM25Config, EmbeddingClient, EmbeddingConfig, HybridConfig, HybridSearcher, RAGPipeline, - SearchMode, SearchOptions, SliceLayer, StorageManager, path_utils, + SearchMode, SearchOptions, SliceLayer, StorageManager, }; -#[allow(dead_code)] -fn parse_features(raw: &str) -> Vec { - raw.split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect() -} - -/// Standard config discovery locations (in priority order) -#[allow(dead_code)] -const CONFIG_SEARCH_PATHS: &[&str] = &[ - "~/.rmcp-servers/rust-memex/config.toml", - "~/.config/rust-memex/config.toml", - "~/.rmcp_servers/rust_memex/config.toml", // legacy underscore path -]; - -/// Discover config file from standard locations -#[allow(dead_code)] -fn discover_config() -> Option { - // 1. Environment variable takes priority - if let Ok(path) = std::env::var("RUST_MEMEX_CONFIG") { - let expanded = shellexpand::tilde(&path).to_string(); - if std::path::Path::new(&expanded).exists() { - return Some(path); - } - } - - // 2. Check standard locations - for path in CONFIG_SEARCH_PATHS { - let expanded = shellexpand::tilde(path).to_string(); - if std::path::Path::new(&expanded).exists() { - return Some(path.to_string()); - } - } - - None -} - -#[allow(dead_code)] -fn load_file_config(path: &str) -> Result { - let (_canonical, contents) = path_utils::safe_read_to_string(path) - .map_err(|e| anyhow::anyhow!("Cannot load config '{}': {}", path, e))?; - toml::from_str(&contents).map_err(Into::into) -} - -/// Load config from explicit path or discover from standard locations -#[allow(dead_code)] -fn load_or_discover_config(explicit_path: Option<&str>) -> Result<(FileConfig, Option)> { - // Explicit path takes priority - if let Some(path) = explicit_path { - return Ok((load_file_config(path)?, Some(path.to_string()))); - } - - // Try to discover config - if let Some(discovered) = discover_config() { - return Ok((load_file_config(&discovered)?, Some(discovered))); - } - - // No config found - use defaults - Ok((FileConfig::default(), None)) -} - use crate::cli::config::*; use crate::cli::formatting::*; + +/// Derive BM25 index path as sibling of the Lance db_path. +/// e.g. "~/.rmcp-servers/rmcp-memex/lancedb" → "/.../rmcp-memex/bm25" +fn bm25_path_from_db(db_path: &str) -> String { + let expanded = shellexpand::tilde(db_path).to_string(); + std::path::Path::new(&expanded) + .parent() + .map(|p| p.join("bm25").to_string_lossy().to_string()) + .unwrap_or_else(|| BM25Config::default().index_path) +} /// Check if auto-optimization should run and execute if needed pub async fn check_and_maybe_optimize( storage: &StorageManager, @@ -138,6 +85,7 @@ pub async fn run_search(config: SearchConfig<'_>) -> Result<()> { let hybrid_config = HybridConfig { mode: search_mode, bm25: BM25Config { + index_path: bm25_path_from_db(&db_path), read_only: true, ..Default::default() }, @@ -464,6 +412,7 @@ pub async fn run_cross_search( let hybrid_config = HybridConfig { mode: search_mode, bm25: BM25Config { + index_path: bm25_path_from_db(&db_path), read_only: true, ..Default::default() }, diff --git a/src/bin/rust_memex.rs b/crates/rust-memex/src/bin/rust_memex.rs similarity index 100% rename from src/bin/rust_memex.rs rename to crates/rust-memex/src/bin/rust_memex.rs diff --git a/src/build.rs b/crates/rust-memex/src/build.rs similarity index 100% rename from src/build.rs rename to crates/rust-memex/src/build.rs diff --git a/src/common.rs b/crates/rust-memex/src/common.rs similarity index 100% rename from src/common.rs rename to crates/rust-memex/src/common.rs diff --git a/crates/rust-memex/src/diagnostics.rs b/crates/rust-memex/src/diagnostics.rs new file mode 100644 index 0000000..8b1cbd1 --- /dev/null +++ b/crates/rust-memex/src/diagnostics.rs @@ -0,0 +1,1280 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use anyhow::Result; +use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, TimeZone, Utc}; +use memex_contracts::{ + audit::{AuditRecommendation, AuditResult}, + stats::{DatabaseStats, NamespaceStats}, + timeline::TimelineEntry, +}; +use serde::{Deserialize, Serialize}; + +use crate::storage::ChromaDocument; +use crate::{IntegrityRecommendation, SliceLayer, StorageManager, TextIntegrityMetrics}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeepStrategy { + /// Keep the document with the earliest ID (lexicographic). + Oldest, + /// Keep the document with the latest ID (lexicographic). + Newest, + /// Keep the first document returned from storage iteration. + HighestScore, +} + +impl From<&str> for KeepStrategy { + fn from(value: &str) -> Self { + match value { + "newest" => Self::Newest, + "highest-score" => Self::HighestScore, + _ => Self::Oldest, + } + } +} + +/// How to group chunks when looking for duplicates. +/// +/// After the v4 schema (`source_hash` + per-chunk `content_hash`) the legacy +/// `content_hash`-only grouping is broken for onion namespaces: every onion +/// layer of the same source has a unique chunk hash, so naive grouping reports +/// zero duplicates. Spec `2026-04-27_kb-transcripts-onion-slicer-fix-spec.md`, +/// P4 fixes this by making `(source_hash, layer)` the default. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DedupGroupBy { + /// `(source_hash, layer)` — preserves onion structure: keeps one chunk + /// per layer per source, removing only true repeats (e.g. `__dupe__` + + /// `__clean__` variants of the same file). This is the post-v4 default. + #[default] + SourceHashLayer, + /// `source_hash` alone — collapses all layers of one source into a single + /// group. Useful when callers already plan to re-slice and only want to + /// deduplicate at the source-document level. + SourceHash, + /// `content_hash` (per-chunk text SHA256) — legacy v3 grouping. After P0 + /// every chunk is unique, so this finds duplicates only when two chunk + /// texts are byte-identical. Kept as opt-in for force-reindex edge cases. + ContentHash, +} + +impl DedupGroupBy { + /// Parse the CLI / HTTP string form. Unknown values fall through to the + /// default (`source-hash-layer`) so older clients keep working safely. + pub fn parse(value: &str) -> Self { + match value { + "content-hash" | "content_hash" => Self::ContentHash, + "source-hash" | "source_hash" => Self::SourceHash, + _ => Self::SourceHashLayer, + } + } + + /// Stable string label for logging / CLI display. + pub fn label(self) -> &'static str { + match self { + Self::SourceHashLayer => "source-hash-layer", + Self::SourceHash => "source-hash", + Self::ContentHash => "content-hash", + } + } +} + +impl From<&str> for DedupGroupBy { + fn from(value: &str) -> Self { + Self::parse(value) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DedupDuplicate { + pub id: String, + pub namespace: String, +} + +/// One duplicate cluster. +/// +/// `content_hash` keeps its legacy field name for wire-compat with older +/// callers. Its semantic is now "the value of the grouping key" — for the +/// post-v4 default this is `:layer`, for `SourceHash` it is +/// the source hash alone, and for the legacy `ContentHash` mode it is the +/// per-chunk text hash. The new `group_key` field carries the same value with +/// a clearer name; new clients should prefer it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DedupGroup { + pub content_hash: String, + #[serde(default)] + pub group_key: String, + pub kept_id: String, + pub kept_namespace: String, + pub removed: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DedupResult { + pub total_docs: usize, + pub unique_docs: usize, + pub duplicate_groups: usize, + pub duplicates_removed: usize, + pub docs_without_hash: usize, + /// Strategy used to bucket chunks into duplicate groups. + /// Defaults to `SourceHashLayer` so legacy serialized payloads keep + /// deserializing into the spec-default shape. + #[serde(default)] + pub group_by: DedupGroupBy, + pub groups: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PurgeQualityCandidate { + pub namespace: String, + pub quality_score: f32, + pub document_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PurgeQualityResult { + pub namespace_filter: Option, + pub threshold: u8, + pub dry_run: bool, + pub purged_namespaces: usize, + pub candidates: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimelineCoverage { + pub earliest: Option, + pub latest: Option, + pub total_days: usize, + pub days_with_data: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimelineReport { + pub namespaces: Vec, + pub entries: Vec, + pub coverage: TimelineCoverage, + pub gaps: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum TimelineBucket { + #[default] + Day, + Hour, +} + +impl TimelineBucket { + pub fn parse(value: &str) -> Self { + match value { + "hour" => Self::Hour, + _ => Self::Day, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TimelineQuery { + pub namespace: Option, + pub since: Option, + pub until: Option, + pub bucket: TimelineBucket, +} + +pub async fn audit_namespaces( + storage: &StorageManager, + namespace: Option<&str>, + threshold: u8, +) -> Result> { + let namespaces: Vec = if let Some(ns) = namespace { + vec![ns.to_string()] + } else { + storage + .list_namespaces() + .await? + .into_iter() + .map(|(name, _count)| name) + .collect() + }; + + let threshold_f32 = threshold as f32 / 100.0; + let mut results = Vec::with_capacity(namespaces.len()); + + for ns in namespaces { + let docs = storage.get_all_in_namespace(&ns).await?; + if docs.is_empty() { + results.push(AuditResult { + namespace: ns, + document_count: 0, + avg_chunk_length: 0, + sentence_integrity: 0.0, + word_integrity: 0.0, + chunk_quality: 0.0, + overall_score: 0.0, + recommendation: AuditRecommendation::Empty, + passes_threshold: false, + }); + continue; + } + + let chunks: Vec = docs.iter().map(|doc| doc.document.clone()).collect(); + let combined_text = chunks.join(" "); + let metrics = TextIntegrityMetrics::compute(&combined_text, &chunks); + + results.push(AuditResult { + namespace: ns, + document_count: docs.len(), + avg_chunk_length: metrics.avg_chunk_length, + sentence_integrity: metrics.sentence_integrity, + word_integrity: metrics.word_integrity, + chunk_quality: metrics.chunk_quality, + overall_score: metrics.overall, + recommendation: integrity_recommendation(metrics.recommendation()), + passes_threshold: metrics.overall >= threshold_f32, + }); + } + + Ok(results) +} + +pub async fn purge_quality_namespaces( + storage: &StorageManager, + namespace: Option<&str>, + threshold: u8, + dry_run: bool, +) -> Result { + let candidates = audit_namespaces(storage, namespace, threshold) + .await? + .into_iter() + .filter(|result| !result.passes_threshold) + .map(|result| PurgeQualityCandidate { + namespace: result.namespace, + quality_score: result.overall_score, + document_count: result.document_count, + }) + .collect::>(); + + let mut purged_namespaces = 0usize; + if !dry_run { + for candidate in &candidates { + storage + .delete_namespace_documents(&candidate.namespace) + .await?; + purged_namespaces += 1; + } + } + + Ok(PurgeQualityResult { + namespace_filter: namespace.map(ToOwned::to_owned), + threshold, + dry_run, + purged_namespaces, + candidates, + }) +} + +pub async fn deduplicate_documents( + storage: &StorageManager, + namespace: Option<&str>, + dry_run: bool, + keep_strategy: KeepStrategy, + cross_namespace: bool, + group_by: DedupGroupBy, +) -> Result { + let all_docs = storage.all_documents(namespace, 1_000_000).await?; + + let mut hash_groups: HashMap> = HashMap::new(); + let mut docs_without_hash = 0usize; + + for doc in &all_docs { + // Build the bucket key per requested strategy. A doc is "without hash" + // if the strategy's required field is empty for this row — the caller + // can then decide whether to backfill or fall back. + let raw_key: Option = match group_by { + DedupGroupBy::ContentHash => doc + .content_hash + .as_deref() + .filter(|hash| !hash.is_empty()) + .map(ToOwned::to_owned), + DedupGroupBy::SourceHash => doc + .source_hash + .as_deref() + .filter(|hash| !hash.is_empty()) + .map(ToOwned::to_owned), + DedupGroupBy::SourceHashLayer => doc + .source_hash + .as_deref() + .filter(|hash| !hash.is_empty()) + .map(|hash| format!("{}|layer{}", hash, doc.layer)), + }; + + let Some(key) = raw_key else { + docs_without_hash += 1; + continue; + }; + + let scoped_key = if cross_namespace { + key + } else { + format!("{}:{}", doc.namespace, key) + }; + hash_groups.entry(scoped_key).or_default().push(doc); + } + + let mut result = DedupResult { + total_docs: all_docs.len(), + unique_docs: 0, + duplicate_groups: 0, + duplicates_removed: 0, + docs_without_hash, + group_by, + groups: Vec::new(), + }; + + for (key, mut docs) in hash_groups { + if docs.len() == 1 { + result.unique_docs += 1; + continue; + } + + match keep_strategy { + KeepStrategy::Oldest => docs.sort_by(|left, right| left.id.cmp(&right.id)), + KeepStrategy::Newest => docs.sort_by(|left, right| right.id.cmp(&left.id)), + KeepStrategy::HighestScore => {} + } + + let kept = docs[0]; + let removed_docs = docs.into_iter().skip(1).collect::>(); + + if !dry_run { + for doc in &removed_docs { + storage.delete_document(&doc.namespace, &doc.id).await?; + } + } + + // Strip the namespace prefix when reporting the group key so consumers + // see the strategy-native value (e.g. `|layer3`). + let display_key = if cross_namespace { + key.clone() + } else { + key.split_once(':') + .map(|(_ns, rest)| rest.to_string()) + .unwrap_or_else(|| key.clone()) + }; + + result.unique_docs += 1; + result.duplicate_groups += 1; + result.duplicates_removed += removed_docs.len(); + result.groups.push(DedupGroup { + content_hash: display_key.clone(), + group_key: display_key, + kept_id: kept.id.clone(), + kept_namespace: kept.namespace.clone(), + removed: removed_docs + .iter() + .map(|doc| DedupDuplicate { + id: doc.id.clone(), + namespace: doc.namespace.clone(), + }) + .collect(), + }); + } + + Ok(result) +} + +/// Result of a backfill-hashes pass. +/// +/// `content_hash` is per-chunk SHA256 (semantics introduced in schema v4). +/// `source_hash` is the SHA256 of the source document text — same value across +/// all four onion layers from one source. Pre-v4 namespaces stored the source +/// hash in `content_hash`; this backfill (a) re-derives a true per-chunk +/// `content_hash`, and (b) preserves the legacy hash as `source_hash` so +/// pre-index dedup works without re-reading source files. +/// +/// Spec: `2026-04-27_kb-transcripts-onion-slicer-fix-spec.md`, P0 backfill. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct BackfillHashesResult { + /// Total documents inspected in the namespace (or across all namespaces). + pub total_docs: usize, + /// Documents that needed a `content_hash` write because the column was + /// either empty or stored the source hash (pre-v4 schema). + pub content_hash_backfilled: usize, + /// Documents that needed a `source_hash` write because the column was + /// empty (pre-v4 schema, or v4 chunk written before the field was wired + /// up end-to-end). + pub source_hash_backfilled: usize, + /// Documents skipped because they already have correct per-chunk + /// `content_hash` AND a populated `source_hash`. + pub already_consistent: usize, + /// Documents that could not be backfilled because the embedding column + /// was missing or zero-length (extremely unlikely; logged in storage warn). + pub skipped_no_embedding: usize, + /// `true` when the caller asked for a dry run (no writes performed). + pub dry_run: bool, +} + +/// Recompute per-chunk `content_hash` and recover `source_hash` for legacy +/// chunks. Safe to run repeatedly; chunks that already match the v4 contract +/// are counted under `already_consistent` and left alone. +/// +/// Strategy: +/// 1. For each chunk, recompute `chunk_hash = SHA256(document_text)`. +/// 2. If the stored `content_hash` differs from `chunk_hash`, the row is +/// pre-v4 — its `content_hash` was actually the source hash. Move it into +/// `source_hash` (when empty) before overwriting `content_hash`. +/// 3. If `source_hash` is still empty after step 2, fall back to copying the +/// new `content_hash` so dedup has *something* to key on (better than +/// nothing — operators can re-index from source for true provenance). +/// 4. Re-write the row by deleting + inserting (LanceDB has no per-row update +/// that accepts a fixed-size vector update for our schema). +/// +fn fmt_duration(secs: f64) -> String { + if secs > 3600.0 { + format!("{:.0}h{:02.0}m", secs / 3600.0, (secs % 3600.0) / 60.0) + } else if secs > 60.0 { + format!("{:.0}m{:02.0}s", secs / 60.0, secs % 60.0) + } else { + format!("{:.0}s", secs) + } +} + +const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +fn emit_backfill_progress( + processed: usize, + total: usize, + started: &std::time::Instant, + last_report: &mut std::time::Instant, +) { + if total == 0 || last_report.elapsed().as_secs() < 10 { + return; + } + *last_report = std::time::Instant::now(); + let pct = (processed as f64 / total as f64 * 100.0).min(100.0); + let elapsed = started.elapsed().as_secs_f64(); + let rate = processed as f64 / elapsed; + let eta = if rate > 0.0 { + (total - processed) as f64 / rate + } else { + 0.0 + }; + let tick = (elapsed as usize / 10) % SPINNER.len(); + eprint!( + "\r {} [{:>6}/{:>6}] {:5.1}% {:.0} docs/s ETA {} ", + SPINNER[tick], + processed, + total, + pct, + rate, + fmt_duration(eta) + ); +} + +/// Spec: `2026-04-27_kb-transcripts-onion-slicer-fix-spec.md`, P0 backfill. +pub async fn backfill_chunk_and_source_hashes( + storage: &StorageManager, + namespace: Option<&str>, + dry_run: bool, +) -> Result { + let mut result = BackfillHashesResult { + dry_run, + ..Default::default() + }; + + // Pre-count total documents for progress reporting. + let total_count = match namespace { + Some(ns) => storage.count_namespace(ns).await.unwrap_or(0), + None => storage.stats().await.map(|s| s.row_count).unwrap_or(0), + }; + + const PAGE: usize = 5_000; + let mut offset = 0; + let mut processed = 0usize; + let started = std::time::Instant::now(); + let mut last_report = started; + + loop { + let page = storage.all_documents_page(namespace, offset, PAGE).await?; + let page_len = page.len(); + if page_len == 0 { + break; + } + result.total_docs += page_len; + + for doc in &page { + processed += 1; + + if doc.embedding.is_empty() { + result.skipped_no_embedding += 1; + emit_backfill_progress(processed, total_count, &started, &mut last_report); + continue; + } + + let true_chunk_hash = crate::rag::compute_content_hash(&doc.document); + let mut needs_content = false; + let mut needs_source = false; + + let new_content_hash = match doc.content_hash.as_deref() { + Some(stored) if stored == true_chunk_hash => stored.to_string(), + Some(_) => { + needs_content = true; + true_chunk_hash.clone() + } + None => { + needs_content = true; + true_chunk_hash.clone() + } + }; + + // Recover source_hash. Pre-v4 chunks stored the source hash under + // `content_hash`; if that was the case, prefer it over the freshly + // computed chunk hash. Otherwise fall back to chunk_hash so dedup + // has a key (better than `None`). + let recovered_source_hash = match (&doc.source_hash, &doc.content_hash) { + (Some(s), _) if !s.is_empty() => s.clone(), + (_, Some(legacy)) if legacy.as_str() != true_chunk_hash.as_str() => { + // Legacy content_hash was actually the source hash. + needs_source = true; + legacy.clone() + } + _ => { + needs_source = doc.source_hash.is_none(); + new_content_hash.clone() + } + }; + + if !needs_content && !needs_source { + result.already_consistent += 1; + emit_backfill_progress(processed, total_count, &started, &mut last_report); + continue; + } + + if needs_content { + result.content_hash_backfilled += 1; + } + if needs_source { + result.source_hash_backfilled += 1; + } + + if dry_run { + emit_backfill_progress(processed, total_count, &started, &mut last_report); + continue; + } + + // Atomic-per-row swap: delete then re-insert with corrected + // hashes. LanceDB lacks an update-with-vector path for our schema. + let new_doc = ChromaDocument { + id: doc.id.clone(), + namespace: doc.namespace.clone(), + embedding: doc.embedding.clone(), + metadata: doc.metadata.clone(), + document: doc.document.clone(), + layer: doc.layer, + parent_id: doc.parent_id.clone(), + children_ids: doc.children_ids.clone(), + keywords: doc.keywords.clone(), + content_hash: Some(new_content_hash.clone()), + source_hash: Some(recovered_source_hash.clone()), + }; + storage.delete_document(&doc.namespace, &doc.id).await?; + storage.add_to_store(vec![new_doc]).await?; + emit_backfill_progress(processed, total_count, &started, &mut last_report); + } + + if page_len < PAGE { + break; + } + offset += page_len; + } + + if total_count > 0 && processed > 0 { + eprintln!( + "\r [{0}/{0}] 100.0% done in {1} ", + processed, + fmt_duration(started.elapsed().as_secs_f64()) + ); + } + + Ok(result) +} + +pub async fn database_stats(storage: &StorageManager) -> Result { + match storage.stats().await { + Ok(stats) => Ok(DatabaseStats { + row_count: stats.row_count, + version_count: stats.version_count, + table_name: stats.table_name, + db_path: stats.db_path, + }), + Err(_) => Ok(DatabaseStats { + row_count: 0, + version_count: 0, + table_name: storage.get_collection_name().to_string(), + db_path: storage.lance_path().to_string(), + }), + } +} + +pub async fn namespace_stats( + storage: &StorageManager, + namespace: Option<&str>, +) -> Result> { + let all_docs = storage.all_documents(namespace, 100_000).await?; + let mut by_namespace: HashMap> = HashMap::new(); + for doc in &all_docs { + by_namespace + .entry(doc.namespace.clone()) + .or_default() + .push(doc); + } + + let mut stats_list = Vec::with_capacity(by_namespace.len()); + for (name, docs) in by_namespace { + let total_chunks = docs.len(); + let mut layer_counts = HashMap::new(); + let mut keyword_counts = HashMap::new(); + let mut dates = Vec::new(); + + for doc in docs { + let layer_name = SliceLayer::from_u8(doc.layer) + .map(|layer| layer.name().to_string()) + .unwrap_or_else(|| "flat".to_string()); + *layer_counts.entry(layer_name).or_insert(0) += 1; + + for keyword in &doc.keywords { + *keyword_counts.entry(keyword.clone()).or_insert(0) += 1; + } + + if let Some(timestamp) = extract_doc_timestamp_string(doc.metadata.as_object()) { + dates.push(timestamp); + } + } + + let mut top_keywords = keyword_counts.into_iter().collect::>(); + top_keywords.sort_by_key(|entry| std::cmp::Reverse(entry.1)); + top_keywords.truncate(10); + dates.sort(); + + stats_list.push(NamespaceStats { + name, + total_chunks, + layer_counts, + top_keywords, + has_timestamps: !dates.is_empty(), + earliest_indexed: dates.first().cloned(), + latest_indexed: dates.last().cloned(), + }); + } + + stats_list.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(stats_list) +} + +pub async fn timeline_report( + storage: &StorageManager, + query: &TimelineQuery, +) -> Result { + let namespaces: Vec = if let Some(namespace) = query.namespace.as_deref() { + vec![namespace.to_string()] + } else { + storage + .list_namespaces() + .await? + .into_iter() + .map(|(name, _count)| name) + .collect() + }; + + let since = query.since.as_deref().and_then(parse_time_bound); + let until = query.until.as_deref().and_then(parse_time_bound); + + let mut timeline: BTreeMap>> = BTreeMap::new(); + let mut all_dates = BTreeSet::new(); + + for namespace in &namespaces { + let docs = storage.get_all_in_namespace(namespace).await?; + for doc in docs { + let Some(timestamp) = extract_doc_timestamp(&doc) else { + all_dates.insert("unknown".to_string()); + *timeline + .entry("unknown".to_string()) + .or_default() + .entry(namespace.clone()) + .or_default() + .entry("unknown".to_string()) + .or_default() += 1; + continue; + }; + + if since.is_some_and(|lower| timestamp < lower) { + continue; + } + if until.is_some_and(|upper| timestamp > upper) { + continue; + } + + let bucket = match query.bucket { + TimelineBucket::Day => timestamp.format("%Y-%m-%d").to_string(), + TimelineBucket::Hour => timestamp.format("%Y-%m-%dT%H:00:00Z").to_string(), + }; + let source = doc + .metadata + .get("source") + .and_then(|value| value.as_str()) + .or_else(|| { + doc.metadata + .get("file_path") + .and_then(|value| value.as_str()) + }) + .map(filename_from_path) + .unwrap_or_else(|| "unknown".to_string()); + + all_dates.insert(bucket.clone()); + *timeline + .entry(bucket) + .or_default() + .entry(namespace.clone()) + .or_default() + .entry(source) + .or_default() += 1; + } + } + + let entries = timeline + .iter() + .flat_map(|(date, namespace_map)| { + namespace_map + .iter() + .flat_map(move |(namespace, source_map)| { + source_map + .iter() + .map(move |(source, chunk_count)| TimelineEntry { + date: date.clone(), + namespace: namespace.clone(), + source: Some(source.clone()), + chunk_count: *chunk_count, + }) + }) + }) + .collect::>(); + + let ordered_dates = all_dates + .iter() + .filter(|date| *date != "unknown") + .collect::>(); + let earliest = ordered_dates.first().map(|date| (*date).clone()); + let latest = ordered_dates.last().map(|date| (*date).clone()); + let gaps = compute_gaps(&ordered_dates, query.bucket); + let total_days = match (ordered_dates.first(), ordered_dates.last()) { + (Some(first), Some(last)) => { + let first_date = timeline_gap_date(first, query.bucket); + let last_date = timeline_gap_date(last, query.bucket); + match (first_date, last_date) { + (Some(first_date), Some(last_date)) => { + (last_date - first_date).num_days() as usize + 1 + } + _ => 0, + } + } + _ => 0, + }; + + Ok(TimelineReport { + namespaces, + entries, + coverage: TimelineCoverage { + earliest, + latest, + total_days, + days_with_data: ordered_dates.len(), + }, + gaps, + }) +} + +fn integrity_recommendation(recommendation: IntegrityRecommendation) -> AuditRecommendation { + match recommendation { + IntegrityRecommendation::Excellent => AuditRecommendation::Excellent, + IntegrityRecommendation::Good => AuditRecommendation::Good, + IntegrityRecommendation::Warn => AuditRecommendation::Warn, + IntegrityRecommendation::Purge => AuditRecommendation::Purge, + } +} + +fn extract_doc_timestamp(doc: &crate::ChromaDocument) -> Option> { + doc.metadata + .get("indexed_at") + .and_then(|value| value.as_str()) + .or_else(|| { + doc.metadata + .get("timestamp") + .and_then(|value| value.as_str()) + }) + .or_else(|| { + doc.metadata + .get("created_at") + .and_then(|value| value.as_str()) + }) + .and_then(parse_iso_or_date) + .or_else(|| { + doc.metadata + .get("path") + .and_then(|value| value.as_str()) + .and_then(extract_date_from_path) + .and_then(|d| parse_iso_or_date(&d)) + }) +} + +fn extract_doc_timestamp_string( + metadata: Option<&serde_json::Map>, +) -> Option { + metadata.and_then(|object| { + object + .iter() + .find_map(|(key, value)| { + if !(key.contains("date") || key.contains("timestamp") || key.contains("time")) { + return None; + } + value.as_str().map(ToOwned::to_owned) + }) + .or_else(|| { + object + .get("path") + .and_then(|value| value.as_str()) + .and_then(extract_date_from_path) + }) + }) +} + +fn extract_date_from_path(path: &str) -> Option { + use std::sync::OnceLock; + + // Look for YYYY-MM-DD or YYYY_MM_DD + static RE_STANDARD: OnceLock = OnceLock::new(); + let re_standard = RE_STANDARD + .get_or_init(|| regex::Regex::new(r"(19\d{2}|20\d{2})[_-](\d{2})[_-](\d{2})").unwrap()); + + if let Some(caps) = re_standard.captures(path) { + let year = caps.get(1)?.as_str(); + let month = caps.get(2)?.as_str(); + let day = caps.get(3)?.as_str(); + return Some(format!("{}-{}-{}", year, month, day)); + } + + // Also look for YYYY_MMDD or YYYYMMDD + static RE_SHORT: OnceLock = OnceLock::new(); + let re_short = + RE_SHORT.get_or_init(|| regex::Regex::new(r"(19\d{2}|20\d{2})_?(\d{2})(\d{2})").unwrap()); + + if let Some(caps) = re_short.captures(path) { + let year = caps.get(1)?.as_str(); + let month = caps.get(2)?.as_str(); + let day = caps.get(3)?.as_str(); + let month_num: u32 = month.parse().ok()?; + let day_num: u32 = day.parse().ok()?; + if (1..=12).contains(&month_num) && (1..=31).contains(&day_num) { + return Some(format!("{}-{}-{}", year, month, day)); + } + } + + None +} + +fn parse_time_bound(input: &str) -> Option> { + if let Some(days_str) = input.strip_suffix('d') + && let Ok(days) = days_str.parse::() + { + return Some(Utc::now() - Duration::days(days)); + } + + if input.len() == 7 + && input.chars().nth(4) == Some('-') + && let Ok(date) = NaiveDate::parse_from_str(&format!("{input}-01"), "%Y-%m-%d") + { + return date + .and_hms_opt(0, 0, 0) + .map(|dt| Utc.from_utc_datetime(&dt)); + } + + parse_iso_or_date(input) +} + +fn parse_iso_or_date(input: &str) -> Option> { + DateTime::parse_from_rfc3339(input) + .map(|dt| dt.with_timezone(&Utc)) + .ok() + .or_else(|| { + NaiveDateTime::parse_from_str(input, "%Y-%m-%dT%H:%M:%S") + .ok() + .map(|dt| Utc.from_utc_datetime(&dt)) + }) + .or_else(|| { + NaiveDate::parse_from_str(input, "%Y-%m-%d") + .ok() + .and_then(|date| date.and_hms_opt(0, 0, 0)) + .map(|dt| Utc.from_utc_datetime(&dt)) + }) +} + +fn filename_from_path(path: &str) -> String { + std::path::Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(path) + .to_string() +} + +fn compute_gaps(dates: &[&String], bucket: TimelineBucket) -> Vec { + let parsed_dates = dates + .iter() + .filter_map(|date| timeline_gap_date(date, bucket)) + .collect::>(); + + let mut gaps = Vec::new(); + for window in parsed_dates.windows(2) { + let diff = window[1] - window[0]; + let missing_units = match bucket { + TimelineBucket::Day => diff.num_days() - 1, + TimelineBucket::Hour => diff.num_hours() - 1, + }; + if missing_units > 0 { + gaps.push(format!( + "{} to {} ({} missing {})", + format_gap_date(window[0], bucket), + format_gap_date(window[1], bucket), + missing_units, + match bucket { + TimelineBucket::Day => "day(s)", + TimelineBucket::Hour => "hour(s)", + } + )); + } + } + gaps +} + +fn timeline_gap_date(date: &str, bucket: TimelineBucket) -> Option> { + match bucket { + TimelineBucket::Day => NaiveDate::parse_from_str(date, "%Y-%m-%d") + .ok() + .and_then(|date| date.and_hms_opt(0, 0, 0)) + .map(|dt| Utc.from_utc_datetime(&dt)), + TimelineBucket::Hour => DateTime::parse_from_rfc3339(date) + .map(|dt| dt.with_timezone(&Utc)) + .ok() + .or_else(|| parse_iso_or_date(date)), + } +} + +fn format_gap_date(date: DateTime, bucket: TimelineBucket) -> String { + match bucket { + TimelineBucket::Day => date.format("%Y-%m-%d").to_string(), + TimelineBucket::Hour => date.format("%Y-%m-%dT%H:00:00Z").to_string(), + } +} + +#[cfg(test)] +mod backfill_tests { + use super::*; + use crate::rag::compute_content_hash; + use crate::storage::ChromaDocument; + use tempfile::TempDir; + + /// Pre-v4 row stored `content_hash = SHA256(source_doc)` for every layer + /// of the same source. After backfill, `content_hash` must become + /// SHA256(chunk_text) and `source_hash` must hold the legacy value so + /// pre-index dedup keeps working. + #[tokio::test] + async fn backfill_promotes_legacy_content_hash_to_source_hash() { + let tmp = TempDir::new().expect("temp dir"); + let db_path = tmp.path().join("lancedb"); + let storage = StorageManager::new_lance_only(db_path.to_str().expect("utf-8 temp db path")) + .await + .expect("storage"); + storage.ensure_collection().await.expect("collection"); + + let namespace = "kb:transcripts-test".to_string(); + let source_text = "full source document text"; + let source_hash = compute_content_hash(source_text); + + // Two chunks from one source, written under the v3 contract: + // both rows carry the source-text hash in `content_hash` and + // leave `source_hash` empty. + let chunk_a_text = "outer summary text"; + let chunk_b_text = "inner detailed text"; + let doc_a = ChromaDocument { + id: "chunk-a".to_string(), + namespace: namespace.clone(), + embedding: vec![0.1_f32; 8], + metadata: serde_json::json!({"path": "doc.md"}), + document: chunk_a_text.to_string(), + layer: 1, + parent_id: None, + children_ids: vec![], + keywords: vec![], + content_hash: Some(source_hash.clone()), + source_hash: None, + }; + let doc_b = ChromaDocument { + id: "chunk-b".to_string(), + namespace: namespace.clone(), + embedding: vec![0.2_f32; 8], + metadata: serde_json::json!({"path": "doc.md"}), + document: chunk_b_text.to_string(), + layer: 3, + parent_id: None, + children_ids: vec![], + keywords: vec![], + content_hash: Some(source_hash.clone()), + source_hash: None, + }; + storage + .add_to_store(vec![doc_a, doc_b]) + .await + .expect("seed pre-v4 rows"); + + let dry = backfill_chunk_and_source_hashes(&storage, Some(&namespace), true) + .await + .expect("dry run"); + assert!(dry.dry_run); + assert_eq!(dry.total_docs, 2); + assert_eq!(dry.content_hash_backfilled, 2); + assert_eq!(dry.source_hash_backfilled, 2); + assert_eq!(dry.already_consistent, 0); + + let live = backfill_chunk_and_source_hashes(&storage, Some(&namespace), false) + .await + .expect("live run"); + assert!(!live.dry_run); + assert_eq!(live.content_hash_backfilled, 2); + assert_eq!(live.source_hash_backfilled, 2); + + let after_a = storage + .get_document(&namespace, "chunk-a") + .await + .expect("lookup a") + .expect("doc-a present"); + assert_eq!( + after_a.content_hash.as_deref(), + Some(compute_content_hash(chunk_a_text)).as_deref() + ); + assert_eq!(after_a.source_hash.as_deref(), Some(source_hash.as_str())); + + let after_b = storage + .get_document(&namespace, "chunk-b") + .await + .expect("lookup b") + .expect("doc-b present"); + assert_eq!( + after_b.content_hash.as_deref(), + Some(compute_content_hash(chunk_b_text)).as_deref() + ); + assert_eq!(after_b.source_hash.as_deref(), Some(source_hash.as_str())); + + // Idempotency: running again leaves the rows alone. + let again = backfill_chunk_and_source_hashes(&storage, Some(&namespace), false) + .await + .expect("idempotent"); + assert_eq!(again.content_hash_backfilled, 0); + assert_eq!(again.source_hash_backfilled, 0); + assert_eq!(again.already_consistent, 2); + } +} + +#[cfg(test)] +mod dedup_grouping_tests { + use super::*; + use crate::rag::compute_content_hash; + use crate::storage::ChromaDocument; + use tempfile::TempDir; + + fn doc( + id: &str, + ns: &str, + layer: u8, + text: &str, + source: &str, + chunk_hash: &str, + ) -> ChromaDocument { + ChromaDocument { + id: id.to_string(), + namespace: ns.to_string(), + embedding: vec![0.1_f32; 8], + metadata: serde_json::json!({}), + document: text.to_string(), + layer, + parent_id: None, + children_ids: vec![], + keywords: vec![], + content_hash: Some(chunk_hash.to_string()), + source_hash: Some(source.to_string()), + } + } + + /// Spec P4: post-v4 default (`source-hash-layer`) must keep the onion + /// intact: with two `__dupe__` + `__clean__` variants of the same source, + /// each layer collapses to a single survivor (4 dups removed for a 4-layer + /// onion), while distinct sources remain untouched. + #[tokio::test] + async fn source_hash_layer_grouping_preserves_onion_structure() { + let tmp = TempDir::new().expect("temp dir"); + let db_path = tmp.path().join("db"); + let storage = StorageManager::new_lance_only(db_path.to_str().expect("utf-8 temp db path")) + .await + .expect("storage"); + storage.ensure_collection().await.expect("collection"); + + let ns = "kb:transcripts-test"; + let source_a = compute_content_hash("source document A — full transcript"); + let source_b = compute_content_hash("source document B — different transcript"); + + // Source A indexed twice (`__dupe__` + `__clean__`): 4 layers × 2 = 8 chunks. + // After source-hash-layer grouping: 4 groups of 2 → 4 duplicates removed. + let mut docs = Vec::new(); + for (suffix, _variant) in [("clean", "clean"), ("dupe", "dupe")].iter() { + for layer in 0u8..4 { + let text = format!("source-A layer-{layer} variant-{suffix}"); + let chunk_hash = compute_content_hash(&text); + docs.push(doc( + &format!("a-{suffix}-l{layer}"), + ns, + layer, + &text, + &source_a, + &chunk_hash, + )); + } + } + // Source B indexed once: 4 unique chunks, must survive. + for layer in 0u8..4 { + let text = format!("source-B layer-{layer}"); + let chunk_hash = compute_content_hash(&text); + docs.push(doc( + &format!("b-l{layer}"), + ns, + layer, + &text, + &source_b, + &chunk_hash, + )); + } + storage + .add_to_store(docs) + .await + .expect("seed dedup fixture"); + + let result = deduplicate_documents( + &storage, + Some(ns), + true, + KeepStrategy::Oldest, + false, + DedupGroupBy::SourceHashLayer, + ) + .await + .expect("dedup"); + + assert_eq!(result.total_docs, 12); + assert_eq!(result.duplicate_groups, 4, "one group per onion layer"); + assert_eq!( + result.duplicates_removed, 4, + "remove one variant per layer, keep the other" + ); + assert_eq!( + result.docs_without_hash, 0, + "every chunk has source_hash populated" + ); + // Source B contributes 4 singleton groups counted as unique_docs. + assert_eq!(result.unique_docs, 4 + 4); + // Group keys must contain the layer suffix so consumers can verify + // per-layer onion preservation. + assert!( + result.groups.iter().all(|g| g.group_key.contains("|layer")), + "source-hash-layer keys must encode layer: {:?}", + result + .groups + .iter() + .map(|g| &g.group_key) + .collect::>() + ); + } + + /// Spec P4 invariant: post-P0 every chunk has a unique `content_hash`, so + /// the legacy grouping must report zero duplicates on a freshly-indexed + /// onion namespace. This test exists to lock that semantic in place — it + /// is the symptom that motivated adding `--group-by`. + #[tokio::test] + async fn content_hash_grouping_finds_zero_duplicates_on_fresh_onion() { + let tmp = TempDir::new().expect("temp dir"); + let db_path = tmp.path().join("db"); + let storage = StorageManager::new_lance_only(db_path.to_str().expect("utf-8 temp db path")) + .await + .expect("storage"); + storage.ensure_collection().await.expect("collection"); + + let ns = "kb:transcripts-test"; + let source_a = compute_content_hash("source document A"); + let mut docs = Vec::new(); + for layer in 0u8..4 { + // Distinct chunk text per layer → unique per-chunk content_hash. + let text = format!("source-A unique-layer-{layer}"); + let chunk_hash = compute_content_hash(&text); + docs.push(doc( + &format!("a-l{layer}"), + ns, + layer, + &text, + &source_a, + &chunk_hash, + )); + } + storage + .add_to_store(docs) + .await + .expect("seed unique chunks"); + + let result = deduplicate_documents( + &storage, + Some(ns), + true, + KeepStrategy::Oldest, + false, + DedupGroupBy::ContentHash, + ) + .await + .expect("dedup"); + + assert_eq!(result.total_docs, 4); + assert_eq!( + result.duplicate_groups, 0, + "post-P0 each chunk has unique content_hash, legacy grouping must find none" + ); + assert_eq!(result.duplicates_removed, 0); + } + + #[test] + fn dedup_group_by_parses_known_aliases_and_falls_back_to_default() { + assert_eq!( + DedupGroupBy::parse("source-hash-layer"), + DedupGroupBy::SourceHashLayer + ); + assert_eq!(DedupGroupBy::parse("source-hash"), DedupGroupBy::SourceHash); + assert_eq!( + DedupGroupBy::parse("source_hash"), + DedupGroupBy::SourceHash, + "underscore form accepted as alias" + ); + assert_eq!( + DedupGroupBy::parse("content-hash"), + DedupGroupBy::ContentHash + ); + assert_eq!( + DedupGroupBy::parse("content_hash"), + DedupGroupBy::ContentHash + ); + // Unknown / empty values resolve to the post-v4 default rather than + // erroring out — keeps older HTTP/CLI clients working. + assert_eq!(DedupGroupBy::parse(""), DedupGroupBy::SourceHashLayer); + assert_eq!(DedupGroupBy::parse("nope"), DedupGroupBy::SourceHashLayer); + } +} diff --git a/src/embeddings/mod.rs b/crates/rust-memex/src/embeddings/mod.rs similarity index 95% rename from src/embeddings/mod.rs rename to crates/rust-memex/src/embeddings/mod.rs index 3f4943c..8540849 100644 --- a/src/embeddings/mod.rs +++ b/crates/rust-memex/src/embeddings/mod.rs @@ -30,6 +30,8 @@ use std::time::Duration; pub const DEFAULT_REQUIRED_DIMENSION: usize = 2560; pub const DEFAULT_OLLAMA_EMBEDDING_MODEL: &str = "qwen3-embedding:4b"; +const DEFAULT_MAX_BATCH_RETRIES: usize = 10; +const DEFAULT_MAX_BATCH_BACKOFF_SECS: u64 = 30; // ============================================================================= // REQUEST/RESPONSE TYPES (OpenAI-compatible) @@ -101,6 +103,22 @@ fn default_embeddings_endpoint() -> String { "/v1/embeddings".to_string() } +fn env_usize(name: &str, default: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(default) +} + +fn env_u64(name: &str, default: u64) -> u64 { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(default) +} + /// Reranker configuration (optional, separate from embedders) #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct RerankerConfig { @@ -547,8 +565,8 @@ impl EmbeddingClient { /// Create a stub client for tests that don't need real embeddings. /// The client will fail on any actual embed() call, but lets McpCore /// be constructed and dispatch protocol-level requests. - #[cfg(test)] - pub(crate) fn stub_for_tests() -> Self { + #[doc(hidden)] + pub fn stub_for_tests() -> Self { Self { client: reqwest::Client::new(), embedder_url: "http://stub:0/v1/embeddings".to_string(), @@ -854,9 +872,16 @@ impl EmbeddingClient { model: self.embedder_model.clone(), }; - // Retry with exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (max) - const MAX_BATCH_RETRIES: usize = 10; - const MAX_BACKOFF_SECS: u64 = 30; + // Retry with exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (max by default). + // Operators can lower this for deterministic failure-policy tests or short smokes. + let max_batch_retries = env_usize( + "RUST_MEMEX_EMBED_BATCH_MAX_RETRIES", + DEFAULT_MAX_BATCH_RETRIES, + ); + let max_backoff_secs = env_u64( + "RUST_MEMEX_EMBED_BATCH_MAX_BACKOFF_SECS", + DEFAULT_MAX_BATCH_BACKOFF_SECS, + ); let mut attempt = 0; loop { @@ -870,27 +895,27 @@ impl EmbeddingClient { { Ok(resp) => resp, Err(e) => { - if attempt >= MAX_BATCH_RETRIES { + if attempt >= max_batch_retries { tracing::error!( "Batch embedding failed after {} retries: {:?}\n URL: {}\n Model: {}", - MAX_BATCH_RETRIES, + max_batch_retries, e, self.embedder_url, self.embedder_model ); return Err(anyhow!( "Embedding request failed after {} retries: {}", - MAX_BATCH_RETRIES, + max_batch_retries, e )); } // Exponential backoff with cap - let backoff_secs = (1u64 << attempt.min(5)).min(MAX_BACKOFF_SECS); + let backoff_secs = (1u64 << attempt.min(5)).min(max_backoff_secs); tracing::warn!( "Embedding request failed (attempt {}/{}), retrying in {}s: {}", attempt, - MAX_BATCH_RETRIES, + max_batch_retries, backoff_secs, e ); @@ -904,21 +929,21 @@ impl EmbeddingClient { let status = response.status(); let body = response.text().await.unwrap_or_default(); - if attempt >= MAX_BATCH_RETRIES { + if attempt >= max_batch_retries { tracing::error!( "Embedding API error after {} retries: {} - {}", - MAX_BATCH_RETRIES, + max_batch_retries, status, body ); return Err(anyhow!("Embedding API error: {} - {}", status, body)); } - let backoff_secs = (1u64 << attempt.min(5)).min(MAX_BACKOFF_SECS); + let backoff_secs = (1u64 << attempt.min(5)).min(max_backoff_secs); tracing::warn!( "Embedding API error (attempt {}/{}), retrying in {}s: {} - {}", attempt, - MAX_BATCH_RETRIES, + max_batch_retries, backoff_secs, status, body @@ -931,14 +956,14 @@ impl EmbeddingClient { let embedding_response: EmbeddingResponse = match response.json().await { Ok(r) => r, Err(e) => { - if attempt >= MAX_BATCH_RETRIES { + if attempt >= max_batch_retries { return Err(anyhow!("Failed to parse embedding response: {}", e)); } - let backoff_secs = (1u64 << attempt.min(5)).min(MAX_BACKOFF_SECS); + let backoff_secs = (1u64 << attempt.min(5)).min(max_backoff_secs); tracing::warn!( "Failed to parse response (attempt {}/{}), retrying in {}s: {}", attempt, - MAX_BATCH_RETRIES, + max_batch_retries, backoff_secs, e ); @@ -1177,10 +1202,18 @@ fn truncate_at_boundary(text: &str, max_chars: usize) -> String { // TOKEN-AWARE VALIDATION // ============================================================================= // -// Embedding models have token limits (e.g., 8192 for qwen3-embedding). +// Embedding models have token limits. qwen3-embedding:8b ships with a +// 40 960-token context window (verified via `ollama show qwen3-embedding:8b`). +// Earlier defaults (8192) were a conservative carry-over from older models and +// caused premature truncation of long transcripts. We keep a 6 000-token margin +// under the real limit (~35 000) for prompt overhead and language drift. // These utilities estimate token counts and validate chunks before embedding. // ============================================================================= +/// Default safe ceiling for qwen3-embedding context window. +/// Real model limit is 40 960 tokens; we leave ~6k margin for safety. +pub const DEFAULT_MAX_TOKENS: usize = 35_000; + /// Token estimation configuration #[derive(Debug, Clone)] pub struct TokenConfig { @@ -1194,8 +1227,8 @@ pub struct TokenConfig { impl Default for TokenConfig { fn default() -> Self { Self { - max_tokens: 8192, // qwen3-embedding default - chars_per_token: 3.0, // Conservative for multilingual + max_tokens: DEFAULT_MAX_TOKENS, + chars_per_token: 3.0, } } } @@ -1204,7 +1237,7 @@ impl TokenConfig { /// Create config for English-only content pub fn english() -> Self { Self { - max_tokens: 8192, + max_tokens: DEFAULT_MAX_TOKENS, chars_per_token: 4.0, } } @@ -1212,7 +1245,7 @@ impl TokenConfig { /// Create config for multilingual/Polish content pub fn for_multilingual_text() -> Self { Self { - max_tokens: 8192, + max_tokens: DEFAULT_MAX_TOKENS, chars_per_token: 2.5, } } @@ -1443,6 +1476,15 @@ mod tests { assert!((2..=4).contains(&tokens)); } + #[test] + fn default_token_ceiling_stays_above_long_transcript_floor() { + let config = TokenConfig::default(); + + assert_eq!(DEFAULT_MAX_TOKENS, 35_000); + assert_eq!(config.max_tokens, DEFAULT_MAX_TOKENS); + assert!(config.max_tokens >= 35_000); + } + #[test] fn test_chunk_validation() { let config = TokenConfig::default().with_max_tokens(100); @@ -1458,11 +1500,11 @@ mod tests { #[test] fn test_safe_chunk_size() { - let config = TokenConfig::default(); // 8192 tokens, 3 chars/token + let config = TokenConfig::default(); // 35_000 tokens, 3 chars/token let safe = safe_chunk_size(&config); - // 8192 * 0.8 * 3 = 19660 chars - assert!(safe > 15000 && safe < 25000); + // 35_000 * 0.8 * 3 = 84_000 chars + assert!(safe > 80_000 && safe < 90_000); } #[test] diff --git a/src/engine.rs b/crates/rust-memex/src/engine.rs similarity index 99% rename from src/engine.rs rename to crates/rust-memex/src/engine.rs index 83313e1..514ae42 100644 --- a/src/engine.rs +++ b/crates/rust-memex/src/engine.rs @@ -398,7 +398,7 @@ impl LayerStats { // Sort by frequency and take top 10 let mut keywords: Vec<_> = keyword_counts.into_iter().collect(); - keywords.sort_by(|a, b| b.1.cmp(&a.1)); + keywords.sort_by_key(|b| std::cmp::Reverse(b.1)); let top_keywords = keywords.into_iter().take(10).map(|(k, _)| k).collect(); Self { @@ -853,7 +853,7 @@ impl MemexEngine { let mut docs = Vec::with_capacity(items.len()); let mut bm25_docs = Vec::new(); - for (item, embedding) in items.iter().zip(embeddings.into_iter()) { + for (item, embedding) in items.iter().zip(embeddings) { let doc = ChromaDocument::new_flat( item.id.clone(), self.namespace.clone(), diff --git a/src/handlers/mod.rs b/crates/rust-memex/src/handlers/mod.rs similarity index 100% rename from src/handlers/mod.rs rename to crates/rust-memex/src/handlers/mod.rs diff --git a/crates/rust-memex/src/http/context_pack.rs b/crates/rust-memex/src/http/context_pack.rs new file mode 100644 index 0000000..7d9c266 --- /dev/null +++ b/crates/rust-memex/src/http/context_pack.rs @@ -0,0 +1,754 @@ +use std::collections::{BTreeMap, HashMap}; +use std::time::Instant; + +use axum::{Json, extract::State, http::StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::rag::{SearchOptions, SliceLayer, compute_content_hash}; +use crate::storage::ChromaDocument; + +use super::{HttpState, SearchResultJson, http_search_mode, search_results_with_mode}; + +const DEFAULT_CONTEXT_LIMIT: usize = 12; +const DEFAULT_MAX_EVIDENCE_PER_CLUSTER: usize = 5; +const DEFAULT_MAX_SOURCE_CHUNKS: usize = 160; + +#[derive(Debug, Clone, Serialize)] +pub struct SearchClusterJson { + pub cluster_id: String, + pub group_by: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_range: Option, + pub representative: SearchResultJson, + pub evidence: Vec, + pub hidden_duplicate_count: usize, + pub hidden_duplicate_ids: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ContextPackRequest { + #[serde(default)] + pub query: Option, + #[serde(default)] + pub namespace: Option, + #[serde(default)] + pub ids: Vec, + #[serde(default = "default_context_limit")] + pub limit: usize, + #[serde(default)] + pub layer: Option, + #[serde(default)] + pub deep: bool, + #[serde(default)] + pub project: Option, + #[serde(default = "default_mode")] + pub mode: String, + #[serde(default = "default_view")] + pub view: String, + #[serde(default = "default_true")] + pub show_raw_evidence: bool, + #[serde(default)] + pub show_decisions_only: bool, + #[serde(default = "default_max_evidence_per_cluster")] + pub max_evidence_per_cluster: usize, + #[serde(default = "default_max_source_chunks")] + pub max_source_chunks: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ContextPackResponse { + pub query: Option, + pub namespace: String, + pub selected_ids: Vec, + pub clusters: Vec, + pub duplicate_count: usize, + pub sources: Vec, + pub markdown: String, + pub elapsed_ms: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ContextPackSourceJson { + pub cluster_id: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + pub indexed_chunk_count: usize, + pub used_core_document: bool, + pub markdown: String, +} + +#[derive(Clone)] +struct ClusterIdentity { + group_by: &'static str, + key: String, + source_hash: Option, + session_id: Option, + source_path: Option, + turn_range: Option, +} + +fn default_context_limit() -> usize { + DEFAULT_CONTEXT_LIMIT +} + +fn default_mode() -> String { + "hybrid".to_string() +} + +fn default_view() -> String { + "full".to_string() +} + +fn default_true() -> bool { + true +} + +fn default_max_evidence_per_cluster() -> usize { + DEFAULT_MAX_EVIDENCE_PER_CLUSTER +} + +fn default_max_source_chunks() -> usize { + DEFAULT_MAX_SOURCE_CHUNKS +} + +pub fn collapse_results(results: &[SearchResultJson]) -> Vec { + collapse_results_with_limit(results, DEFAULT_MAX_EVIDENCE_PER_CLUSTER) +} + +pub fn collapse_results_with_limit( + results: &[SearchResultJson], + max_evidence_per_cluster: usize, +) -> Vec { + let mut order: Vec = Vec::new(); + let mut grouped: BTreeMap> = BTreeMap::new(); + let mut identities: HashMap = HashMap::new(); + let max_evidence_per_cluster = max_evidence_per_cluster.max(1); + + for result in results { + let identity = cluster_identity(result); + if !grouped.contains_key(&identity.key) { + order.push(identity.key.clone()); + identities.insert(identity.key.clone(), identity.clone()); + } + grouped + .entry(identity.key) + .or_default() + .push(result.clone()); + } + + order + .into_iter() + .filter_map(|key| { + let mut evidence = grouped.remove(&key)?; + evidence.sort_by(|left, right| { + right + .score + .partial_cmp(&left.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let representative = evidence.first()?.clone(); + let identity = identities.remove(&key)?; + let hidden_duplicate_ids = evidence + .iter() + .skip(1) + .map(|result| result.id.clone()) + .collect::>(); + let hidden_duplicate_count = evidence.len().saturating_sub(1); + evidence.truncate(max_evidence_per_cluster); + + Some(SearchClusterJson { + cluster_id: identity.key, + group_by: identity.group_by.to_string(), + source_hash: identity.source_hash, + session_id: identity.session_id, + source_path: identity.source_path, + turn_range: identity.turn_range, + representative, + evidence, + hidden_duplicate_count, + hidden_duplicate_ids, + }) + }) + .collect() +} + +pub async fn context_pack_handler( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let start = Instant::now(); + let namespace = req + .namespace + .clone() + .unwrap_or_else(|| "default".to_string()); + + let selected = if req.ids.is_empty() { + let Some(query) = req.query.as_deref() else { + return Err(( + StatusCode::BAD_REQUEST, + "Provide either query or ids for context-pack".to_string(), + )); + }; + let layer_filter = if req.deep { + None + } else { + req.layer + .and_then(SliceLayer::from_u8) + .or(Some(SliceLayer::Outer)) + }; + let options = SearchOptions { + layer_filter, + project_filter: req.project.clone().filter(|value| !value.trim().is_empty()), + }; + search_results_with_mode( + &state, + Some(namespace.as_str()), + query, + req.limit, + http_search_mode(req.mode.as_str()), + options, + ) + .await + .map_err(internal_error)? + } else { + let mut fetched = Vec::with_capacity(req.ids.len()); + for id in &req.ids { + if let Some(result) = state + .rag + .lookup_memory(namespace.as_str(), id) + .await + .map_err(internal_error)? + { + fetched.push(SearchResultJson::from(result)); + } + } + fetched + }; + + let selected_ids = selected + .iter() + .map(|result| result.id.clone()) + .collect::>(); + let clusters = collapse_results_with_limit(&selected, req.max_evidence_per_cluster); + let duplicate_count = selected.len().saturating_sub(clusters.len()); + let view = if req.show_decisions_only { + "decisions" + } else { + req.view.as_str() + }; + let all_docs = state + .rag + .storage_manager() + .all_documents(Some(namespace.as_str()), 100_000) + .await + .map_err(internal_error)?; + let sources = rebuild_sources(&clusters, &all_docs, req.max_source_chunks.max(1), view); + let markdown = render_context_pack_markdown( + req.query.as_deref(), + namespace.as_str(), + &clusters, + &sources, + req.show_raw_evidence, + req.max_evidence_per_cluster.max(1), + ); + + Ok(Json(ContextPackResponse { + query: req.query, + namespace, + selected_ids, + clusters, + duplicate_count, + sources, + markdown, + elapsed_ms: start.elapsed().as_millis() as u64, + })) +} + +fn internal_error(error: anyhow::Error) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) +} + +fn cluster_identity(result: &SearchResultJson) -> ClusterIdentity { + let source_hash = metadata_text(&result.metadata, &["source_hash", "file_hash"]); + let source_path = metadata_text(&result.metadata, &["source_path", "path"]); + let exact_content_hash = metadata_text(&result.metadata, &["chunk_hash", "content_hash"]); + let session_id = metadata_text( + &result.metadata, + &["session_id", "conversation_id", "run_id"], + ) + .or_else(|| source_path.as_deref().and_then(session_id_from_path)); + let turn_range = metadata_text( + &result.metadata, + &["turn_range", "line_range", "chunk_range", "message_range"], + ) + .or_else(|| numeric_range(&result.metadata, "turn_start", "turn_end")) + .or_else(|| numeric_range(&result.metadata, "line_start", "line_end")); + + let (group_by, raw_key) = if let Some(value) = source_hash.as_deref() { + ("source_hash", value.to_string()) + } else if let Some(value) = session_id.as_deref() { + ("session_id", value.to_string()) + } else if let Some(value) = source_path.as_deref() { + ("source_path", value.to_string()) + } else if let Some(value) = result.parent_id.as_deref() { + ("turn_range", value.to_string()) + } else if let Some(value) = turn_range.as_deref() { + ("turn_range", value.to_string()) + } else if let Some(value) = exact_content_hash.as_deref() { + ("content_hash", value.to_string()) + } else { + ("normalized_text", normalized_text_hash(&result.text)) + }; + + ClusterIdentity { + group_by, + key: format!("{}:{}", group_by, raw_key), + source_hash, + session_id, + source_path, + turn_range, + } +} + +fn rebuild_sources( + clusters: &[SearchClusterJson], + all_docs: &[ChromaDocument], + max_source_chunks: usize, + view: &str, +) -> Vec { + clusters + .iter() + .map(|cluster| { + let mut matching = all_docs + .iter() + .filter(|doc| source_matches_cluster(doc, cluster)) + .cloned() + .collect::>(); + + matching.sort_by_key(source_sort_key); + + let used_core_document = matching.iter().any(|doc| doc.layer == 4); + if used_core_document { + matching.retain(|doc| doc.layer == 4); + } + matching.truncate(max_source_chunks); + + let markdown = if matching.is_empty() { + render_selected_evidence(cluster, DEFAULT_MAX_EVIDENCE_PER_CLUSTER) + } else { + render_rebuilt_source(&matching, view) + }; + + ContextPackSourceJson { + cluster_id: cluster.cluster_id.clone(), + status: if matching.is_empty() { + "source_missing_selected_evidence_only".to_string() + } else if used_core_document { + "rebuilt_from_core_chunk".to_string() + } else { + "rebuilt_from_index_chunks".to_string() + }, + source_path: cluster.source_path.clone(), + source_hash: cluster.source_hash.clone(), + session_id: cluster.session_id.clone(), + indexed_chunk_count: matching.len(), + used_core_document, + markdown, + } + }) + .collect() +} + +fn source_matches_cluster(doc: &ChromaDocument, cluster: &SearchClusterJson) -> bool { + if let Some(source_hash) = cluster.source_hash.as_deref() { + return doc.source_hash.as_deref() == Some(source_hash) + || metadata_text(&doc.metadata, &["source_hash", "file_hash"]).as_deref() + == Some(source_hash); + } + + if let Some(source_path) = cluster.source_path.as_deref() { + return metadata_text(&doc.metadata, &["source_path", "path"]).as_deref() + == Some(source_path); + } + + if let Some(session_id) = cluster.session_id.as_deref() { + return metadata_text(&doc.metadata, &["session_id", "conversation_id", "run_id"]) + .or_else(|| { + metadata_text(&doc.metadata, &["source_path", "path"]) + .as_deref() + .and_then(session_id_from_path) + }) + .as_deref() + == Some(session_id); + } + + false +} + +fn source_sort_key(doc: &ChromaDocument) -> (u8, String) { + let layer_rank = match doc.layer { + 4 => 0, + 3 => 1, + 2 => 2, + 1 => 3, + _ => 4, + }; + (layer_rank, doc.id.clone()) +} + +fn render_context_pack_markdown( + query: Option<&str>, + namespace: &str, + clusters: &[SearchClusterJson], + sources: &[ContextPackSourceJson], + show_raw_evidence: bool, + max_evidence_per_cluster: usize, +) -> String { + let mut out = String::new(); + out.push_str("# rust-memex Context Pack\n\n"); + out.push_str(&format!("- Namespace: `{}`\n", namespace)); + if let Some(query) = query { + out.push_str(&format!("- Query: `{}`\n", query)); + } + out.push_str(&format!("- Collapsed clusters: {}\n", clusters.len())); + out.push_str(&format!( + "- Hidden duplicate hits: {}\n\n", + clusters + .iter() + .map(|cluster| cluster.hidden_duplicate_count) + .sum::() + )); + + for (idx, cluster) in clusters.iter().enumerate() { + out.push_str(&format!("## {}. {}\n\n", idx + 1, cluster_title(cluster))); + out.push_str(&format!("- Grouped by: `{}`\n", cluster.group_by)); + if let Some(path) = cluster.source_path.as_deref() { + out.push_str(&format!("- Source path: `{}`\n", path)); + } + if let Some(session_id) = cluster.session_id.as_deref() { + out.push_str(&format!("- Session: `{}`\n", session_id)); + } + if let Some(turn_range) = cluster.turn_range.as_deref() { + out.push_str(&format!("- Turn range: `{}`\n", turn_range)); + } + out.push_str(&format!( + "- Hidden duplicates: {}\n\n", + cluster.hidden_duplicate_count + )); + + if show_raw_evidence { + out.push_str("### Raw Evidence\n\n"); + out.push_str(&render_selected_evidence(cluster, max_evidence_per_cluster)); + out.push('\n'); + } + + if let Some(source) = sources + .iter() + .find(|source| source.cluster_id == cluster.cluster_id) + { + out.push_str("### Rebuilt Context\n\n"); + out.push_str(&format!("Status: `{}`\n\n", source.status)); + out.push_str(&source.markdown); + out.push('\n'); + } + } + + out +} + +fn render_selected_evidence(cluster: &SearchClusterJson, limit: usize) -> String { + let mut out = String::new(); + for evidence in cluster.evidence.iter().take(limit) { + out.push_str(&format!( + "- `{}` score={:.3} layer={}\n\n{}\n\n", + evidence.id, + evidence.score, + evidence.layer.as_deref().unwrap_or("flat"), + evidence.text.trim() + )); + } + out +} + +fn render_rebuilt_source(docs: &[ChromaDocument], view: &str) -> String { + let decisions_only = view.eq_ignore_ascii_case("decisions"); + let mut out = String::new(); + for doc in docs { + let text = if decisions_only { + decision_lines(&doc.document) + } else { + doc.document.clone() + }; + if text.trim().is_empty() { + continue; + } + out.push_str(&format!( + "#### `{}` layer={}\n\n{}\n\n", + doc.id, + doc.slice_layer() + .map(|layer| layer.name().to_string()) + .unwrap_or_else(|| "flat".to_string()), + text.trim() + )); + } + if out.is_empty() && decisions_only { + "No decision-like lines found in rebuilt chunks.\n".to_string() + } else { + out + } +} + +fn decision_lines(text: &str) -> String { + text.lines() + .filter(|line| { + let lowered = line.to_ascii_lowercase(); + lowered.contains("decision") + || lowered.contains("decided") + || lowered.contains("wybór") + || lowered.contains("ustal") + || lowered.contains("wniosek") + || lowered.contains("verdict") + }) + .collect::>() + .join("\n") +} + +fn cluster_title(cluster: &SearchClusterJson) -> String { + cluster + .source_path + .as_deref() + .and_then(|path| std::path::Path::new(path).file_name()) + .and_then(|name| name.to_str()) + .map(ToOwned::to_owned) + .or_else(|| cluster.session_id.clone()) + .unwrap_or_else(|| cluster.representative.id.clone()) +} + +fn metadata_text(metadata: &Value, keys: &[&str]) -> Option { + keys.iter() + .filter_map(|key| metadata.get(*key)) + .filter_map(|value| value.as_str()) + .map(str::trim) + .find(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn numeric_range(metadata: &Value, start: &str, end: &str) -> Option { + let start = metadata.get(start).and_then(Value::as_i64)?; + let end = metadata.get(end).and_then(Value::as_i64)?; + Some(format!("{}..{}", start, end)) +} + +fn session_id_from_path(path: &str) -> Option { + let file_name = std::path::Path::new(path).file_name()?.to_str()?; + file_name + .split("__") + .find(|part| looks_like_session_id(part)) + .map(ToOwned::to_owned) + .or_else(|| { + file_name + .split(|ch: char| !(ch.is_ascii_hexdigit() || ch == '-')) + .find(|part| looks_like_session_id(part)) + .map(ToOwned::to_owned) + }) +} + +fn looks_like_session_id(value: &str) -> bool { + let hexish = value + .chars() + .filter(|ch| ch.is_ascii_hexdigit() || *ch == '-') + .count(); + value.len() >= 20 && hexish == value.len() && value.contains('-') +} + +fn normalized_text_hash(text: &str) -> String { + let normalized = text + .split_whitespace() + .map(|word| { + word.chars() + .filter(|ch| ch.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }) + .filter(|word| !word.is_empty()) + .collect::>() + .join(" "); + compute_content_hash(&normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn result(id: &str, path: &str, score: f32, text: &str) -> SearchResultJson { + SearchResultJson { + id: id.to_string(), + namespace: "kb:transcripts".to_string(), + text: text.to_string(), + score, + metadata: json!({"path": path}), + layer: Some("outer".to_string()), + parent_id: None, + children_ids: vec![], + keywords: vec![], + can_expand: false, + can_drill_up: false, + } + } + + fn result_with_metadata( + id: &str, + score: f32, + text: &str, + metadata: serde_json::Value, + ) -> SearchResultJson { + SearchResultJson { + id: id.to_string(), + namespace: "kb:transcripts".to_string(), + text: text.to_string(), + score, + metadata, + layer: Some("outer".to_string()), + parent_id: None, + children_ids: vec![], + keywords: vec![], + can_expand: false, + can_drill_up: false, + } + } + + #[test] + fn collapse_groups_by_source_path_and_preserves_best_evidence() { + let results = vec![ + result("a", "/tmp/session.md", 0.7, "first hit"), + result("b", "/tmp/session.md", 0.9, "better hit"), + result("c", "/tmp/other.md", 0.4, "other"), + ]; + + let clusters = collapse_results(&results); + + assert_eq!(clusters.len(), 2); + assert_eq!(clusters[0].group_by, "source_path"); + assert_eq!(clusters[0].representative.id, "b"); + assert_eq!(clusters[0].hidden_duplicate_count, 1); + assert_eq!(clusters[0].hidden_duplicate_ids, vec!["a"]); + } + + #[test] + fn collapse_prefers_session_over_chunk_paths_and_keeps_limited_evidence() { + let results = vec![ + result_with_metadata( + "a", + 0.8, + "turn one", + json!({"session_id": "019d749e-5b30-7f33-8bb4-a3a6e21b66c4", "path": "/tmp/chunk-1.md"}), + ), + result_with_metadata( + "b", + 0.9, + "turn two", + json!({"session_id": "019d749e-5b30-7f33-8bb4-a3a6e21b66c4", "path": "/tmp/chunk-2.md"}), + ), + result_with_metadata( + "c", + 0.7, + "turn three", + json!({"session_id": "019d749e-5b30-7f33-8bb4-a3a6e21b66c4", "path": "/tmp/chunk-3.md"}), + ), + ]; + + let clusters = collapse_results_with_limit(&results, 2); + + assert_eq!(clusters.len(), 1); + assert_eq!(clusters[0].group_by, "session_id"); + assert_eq!(clusters[0].representative.id, "b"); + assert_eq!(clusters[0].evidence.len(), 2); + assert_eq!(clusters[0].hidden_duplicate_count, 2); + assert_eq!(clusters[0].hidden_duplicate_ids, vec!["a", "c"]); + } + + #[test] + fn collapse_falls_back_to_content_hash_then_normalized_text() { + let hashed = vec![ + result_with_metadata( + "a", + 0.5, + "same logical chunk", + json!({"chunk_hash": "chunk-a"}), + ), + result_with_metadata( + "b", + 0.6, + "same logical chunk copy", + json!({"chunk_hash": "chunk-a"}), + ), + ]; + let hashed_clusters = collapse_results(&hashed); + + assert_eq!(hashed_clusters.len(), 1); + assert_eq!(hashed_clusters[0].group_by, "content_hash"); + assert_eq!(hashed_clusters[0].representative.id, "b"); + + let normalized = vec![ + result_with_metadata("c", 0.4, "Auth, license: Vista!", json!({})), + result_with_metadata("d", 0.7, "auth license vista", json!({})), + ]; + let normalized_clusters = collapse_results(&normalized); + + assert_eq!(normalized_clusters.len(), 1); + assert_eq!(normalized_clusters[0].group_by, "normalized_text"); + } + + #[test] + fn rebuilt_source_prefers_core_chunk_for_full_markdown() { + let cluster = collapse_results(&[result( + "outer", + "/tmp/codex__019d749e-5b30-7f33-8bb4-a3a6e21b66c4__clean.md", + 0.8, + "outer", + )]) + .pop() + .unwrap(); + let docs = vec![ + ChromaDocument::new_flat( + "outer".to_string(), + "kb:transcripts".to_string(), + vec![], + json!({"path": "/tmp/codex__019d749e-5b30-7f33-8bb4-a3a6e21b66c4__clean.md"}), + "outer summary".to_string(), + ), + ChromaDocument { + id: "core".to_string(), + namespace: "kb:transcripts".to_string(), + embedding: vec![], + metadata: json!({"path": "/tmp/codex__019d749e-5b30-7f33-8bb4-a3a6e21b66c4__clean.md"}), + document: "# Full transcript\n\nDecision: keep chunks as index units.".to_string(), + layer: 4, + parent_id: None, + children_ids: vec![], + keywords: vec![], + content_hash: None, + source_hash: None, + }, + ]; + + let sources = rebuild_sources(&[cluster], &docs, 20, "full"); + + assert_eq!(sources[0].status, "rebuilt_from_core_chunk"); + assert!(sources[0].markdown.contains("# Full transcript")); + assert!(!sources[0].markdown.contains("outer summary")); + } +} diff --git a/crates/rust-memex/src/http/lifecycle.rs b/crates/rust-memex/src/http/lifecycle.rs new file mode 100644 index 0000000..eadda24 --- /dev/null +++ b/crates/rust-memex/src/http/lifecycle.rs @@ -0,0 +1,396 @@ +use std::convert::Infallible; +use std::io; +use std::path::PathBuf; +use std::time::Duration; + +use axum::{ + Json, Router, + body::{Body, Bytes}, + extract::{Multipart, State}, + http::{HeaderValue, StatusCode, header}, + response::{ + IntoResponse, + sse::{Event, Sse}, + }, + routing::post, +}; +use futures::StreamExt; +use memex_contracts::progress::SseEvent; +use serde::Deserialize; +use serde_json::{Value, json}; +use tokio::sync::mpsc; + +use crate::{ + ChunkerKind, ReindexJob, ReprocessJob, SliceMode, default_reindexed_namespace, + export_namespace_jsonl_stream, import_jsonl_bytes_stream, migrate_namespace_atomic, + reindex_namespace, reprocess_jsonl_file, +}; + +use super::HttpState; + +#[derive(Debug, Deserialize)] +struct ReprocessRequest { + input_path: String, + target_namespace: String, + slice_mode: String, + #[serde(default)] + chunker: Option, + #[serde(default)] + preprocess: bool, + #[serde(default)] + skip_existing: bool, + #[serde(default)] + allow_duplicates: bool, +} + +#[derive(Debug, Deserialize)] +struct ReindexRequest { + source_namespace: String, + target_namespace: Option, + slice_mode: String, + #[serde(default)] + chunker: Option, + #[serde(default)] + preprocess: bool, + #[serde(default)] + skip_existing: bool, + #[serde(default)] + allow_duplicates: bool, +} + +#[derive(Debug, Deserialize)] +struct ExportRequest { + namespace: String, + #[serde(default)] + include_embeddings: bool, +} + +#[derive(Debug, Deserialize)] +struct MigrateNamespaceRequest { + from: String, + to: String, +} + +pub(super) fn routes() -> Router { + Router::new() + .route("/sse/reprocess", post(sse_reprocess_handler)) + .route("/sse/reindex", post(sse_reindex_handler)) + .route("/api/export", post(export_handler)) + .route("/api/import", post(import_handler)) + .route("/api/migrate-namespace", post(migrate_namespace_handler)) +} + +async fn sse_reprocess_handler( + State(state): State, + Json(request): Json, +) -> Result>>, (StatusCode, String)> { + let slice_mode: SliceMode = request.slice_mode.parse().map_err(|err| { + ( + StatusCode::BAD_REQUEST, + format!("invalid slice_mode '{}': {}", request.slice_mode, err), + ) + })?; + + let input_path = PathBuf::from(request.input_path.clone()); + let target_namespace = request.target_namespace.clone(); + let slice_mode_name = request.slice_mode.clone(); + let chunker = request.chunker; + let preprocess = request.preprocess; + let skip_existing = request.skip_existing; + let allow_duplicates = request.allow_duplicates; + let (tx, mut rx) = mpsc::unbounded_channel(); + let rag = state.rag.clone(); + + tokio::spawn(async move { + let _ = tx.send(sse_event( + "start", + json!({ + "input_path": input_path.display().to_string(), + "target_namespace": target_namespace.clone(), + "slice_mode": slice_mode_name.clone(), + "chunker": chunker.map(|kind| kind.name()), + "preprocess": preprocess, + "skip_existing": skip_existing, + "allow_duplicates": allow_duplicates, + }), + )); + + let result = reprocess_jsonl_file( + rag, + ReprocessJob { + input_path: input_path.clone(), + target_namespace: target_namespace.clone(), + slice_mode, + chunker, + preprocess, + skip_existing, + allow_duplicates, + dry_run: false, + }, + |progress| { + let _ = tx.send(sse_event( + "progress", + serde_json::to_value(progress).unwrap_or(Value::Null), + )); + }, + ) + .await; + + match result { + Ok(summary) => { + let _ = tx.send(sse_event( + "result", + serde_json::to_value(summary).unwrap_or(Value::Null), + )); + } + Err(err) => { + let _ = tx.send(sse_event("error", json!({ "error": err.to_string() }))); + } + } + }); + + let stream = async_stream::stream! { + while let Some(event) = rx.recv().await { + yield Ok(to_axum_event(event)); + } + }; + + Ok(Sse::new(stream).keep_alive( + axum::response::sse::KeepAlive::new() + .interval(Duration::from_secs(15)) + .text("ping"), + )) +} + +async fn sse_reindex_handler( + State(state): State, + Json(request): Json, +) -> Result>>, (StatusCode, String)> { + let slice_mode: SliceMode = request.slice_mode.parse().map_err(|err| { + ( + StatusCode::BAD_REQUEST, + format!("invalid slice_mode '{}': {}", request.slice_mode, err), + ) + })?; + + let source_namespace = request.source_namespace.clone(); + let target_namespace = request + .target_namespace + .clone() + .unwrap_or_else(|| default_reindexed_namespace(&source_namespace)); + let slice_mode_name = request.slice_mode.clone(); + let chunker = request.chunker; + let preprocess = request.preprocess; + let skip_existing = request.skip_existing; + let allow_duplicates = request.allow_duplicates; + let (tx, mut rx) = mpsc::unbounded_channel(); + let rag = state.rag.clone(); + + tokio::spawn(async move { + let _ = tx.send(sse_event( + "start", + json!({ + "source_namespace": source_namespace.clone(), + "target_namespace": target_namespace.clone(), + "slice_mode": slice_mode_name.clone(), + "chunker": chunker.map(|kind| kind.name()), + "preprocess": preprocess, + "skip_existing": skip_existing, + "allow_duplicates": allow_duplicates, + }), + )); + + let result = reindex_namespace( + rag, + ReindexJob { + source_namespace: source_namespace.clone(), + target_namespace: target_namespace.clone(), + slice_mode, + chunker, + preprocess, + skip_existing, + allow_duplicates, + dry_run: false, + }, + |progress| { + let _ = tx.send(sse_event( + "progress", + serde_json::to_value(progress).unwrap_or(Value::Null), + )); + }, + ) + .await; + + match result { + Ok(summary) => { + let _ = tx.send(sse_event( + "result", + serde_json::to_value(summary).unwrap_or(Value::Null), + )); + } + Err(err) => { + let _ = tx.send(sse_event("error", json!({ "error": err.to_string() }))); + } + } + }); + + let stream = async_stream::stream! { + while let Some(event) = rx.recv().await { + yield Ok(to_axum_event(event)); + } + }; + + Ok(Sse::new(stream).keep_alive( + axum::response::sse::KeepAlive::new() + .interval(Duration::from_secs(15)) + .text("ping"), + )) +} + +async fn export_handler( + State(state): State, + Json(request): Json, +) -> impl IntoResponse { + let namespace = request.namespace.clone(); + let stream = export_namespace_jsonl_stream( + state.rag.storage_manager(), + request.namespace.clone(), + request.include_embeddings, + ) + .map(|item| item.map(Bytes::from).map_err(io::Error::other)); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/x-ndjson"), + ); + if let Ok(value) = HeaderValue::from_str(&format!( + "attachment; filename=\"{}.jsonl\"", + sanitize_filename(&namespace) + )) { + headers.insert(header::CONTENT_DISPOSITION, value); + } + + (StatusCode::OK, headers, Body::from_stream(stream)) +} + +async fn import_handler( + State(state): State, + mut multipart: Multipart, +) -> Result, (StatusCode, String)> { + let mut namespace = None; + let mut skip_existing = false; + let mut imported_count = None; + + while let Some(field) = multipart.next_field().await.map_err(internal_error)? { + let field_name = field.name().unwrap_or_default().to_string(); + match field_name.as_str() { + "namespace" => { + namespace = Some(field.text().await.map_err(internal_error)?); + } + "skip_existing" => { + let value = field.text().await.map_err(internal_error)?; + skip_existing = parse_bool_field(&value)?; + } + "file" => { + let namespace = namespace.clone().ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + "missing multipart field 'namespace' before file upload".to_string(), + ) + })?; + let mut field = field; + let stream = async_stream::stream! { + loop { + match field.chunk().await { + Ok(Some(chunk)) => yield Ok::(chunk), + Ok(None) => break, + Err(err) => { + yield Err(err.to_string()); + break; + } + } + } + }; + futures::pin_mut!(stream); + let outcome = + import_jsonl_bytes_stream(state.rag.clone(), namespace, skip_existing, stream) + .await + .map_err(internal_error)?; + imported_count = Some(outcome.imported_count); + break; + } + _ => {} + } + } + + if namespace.is_none() { + return Err(( + StatusCode::BAD_REQUEST, + "missing multipart field 'namespace'".to_string(), + )); + } + + let imported_count = imported_count.ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + "missing multipart file field 'file'".to_string(), + ) + })?; + + Ok(Json(json!({ "imported_count": imported_count }))) +} + +async fn migrate_namespace_handler( + State(state): State, + Json(request): Json, +) -> Result, (StatusCode, String)> { + let storage = state.rag.storage_manager(); + let outcome = migrate_namespace_atomic(storage.as_ref(), &request.from, &request.to) + .await + .map_err(internal_error)?; + Ok(Json(json!({ "migrated_chunks": outcome.migrated_chunks }))) +} + +fn sse_event(event: &str, data: Value) -> SseEvent { + SseEvent { + event: event.to_string(), + id: None, + data, + } +} + +fn to_axum_event(event: SseEvent) -> Event { + let mut axum_event = Event::default() + .event(event.event) + .data(event.data.to_string()); + if let Some(id) = event.id { + axum_event = axum_event.id(id); + } + axum_event +} + +fn sanitize_filename(namespace: &str) -> String { + namespace + .chars() + .map(|ch| match ch { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => ch, + _ => '_', + }) + .collect() +} + +fn parse_bool_field(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" | "" => Ok(false), + other => Err(( + StatusCode::BAD_REQUEST, + format!("invalid boolean field value '{}'", other), + )), + } +} + +fn internal_error(err: impl std::fmt::Display) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) +} diff --git a/src/http/mod.rs b/crates/rust-memex/src/http/mod.rs similarity index 55% rename from src/http/mod.rs rename to crates/rust-memex/src/http/mod.rs index 0352a8b..7b13e4a 100644 --- a/src/http/mod.rs +++ b/crates/rust-memex/src/http/mod.rs @@ -18,9 +18,19 @@ //! - POST /search - Search documents //! - GET /sse/search - SSE streaming search //! - GET /sse/namespaces - SSE streaming namespace listing with summaries +//! - POST /sse/compact - SSE streaming database compaction +//! - POST /sse/cleanup - SSE streaming version cleanup (>7 days) +//! - POST /sse/gc - SSE streaming orphan garbage collection //! - POST /sse/optimize - SSE streaming database optimize (compact + prune) +//! - POST /sse/reprocess - SSE streaming namespace rebuild from JSONL +//! - POST /sse/reindex - SSE streaming namespace rebuild from namespace source //! - POST /upsert - Upsert document (memory_upsert) //! - POST /index - Index text with full pipeline +//! - POST /api/merge - Merge multiple LanceDB stores into one target +//! - POST /api/repair-writes - Inspect or repair cross-store recovery ledgers +//! - POST /api/export - Stream a namespace as JSONL +//! - POST /api/import - Import JSONL into a namespace +//! - POST /api/migrate-namespace - Atomically rename a namespace //! - GET /expand/:ns/:id - Expand onion slice (get children) //! - GET /parent/:ns/:id - Get parent slice (drill up) //! - DELETE /ns/:namespace - Purge namespace @@ -31,16 +41,20 @@ //! //! Vibecrafted with AI Agents by Loctree (c)2026 Loctree -use std::collections::HashMap; +mod context_pack; +mod lifecycle; +mod recovery; + +use std::collections::{BTreeMap, HashMap}; use std::convert::Infallible; use std::net::IpAddr; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use axum::{ Json, Router, extract::{Path, Query, Request, State}, - http::{HeaderValue, Method, StatusCode}, + http::{HeaderMap, HeaderValue, Method, StatusCode, header}, middleware::{self, Next}, response::{ Html, IntoResponse, @@ -48,16 +62,36 @@ use axum::{ }, routing::{delete, get, post}, }; +pub use memex_contracts::audit::{AuditRecommendation, AuditResult, ChunkQuality, QualityTier}; +pub use memex_contracts::progress::{ + AuditProgress, CompactProgress, MergeProgress, ReindexProgress, RepairResult, + ReprocessProgress, SseEvent, +}; +pub use memex_contracts::stats::{DatabaseStats, NamespaceStats, StorageMetrics}; +pub use memex_contracts::timeline::{TimeRange, TimelineEntry, TimelineFilter}; +use openidconnect::{ + AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, PkceCodeChallenge, + PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, + core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata}, +}; use serde::{Deserialize, Serialize}; use serde_json::json; +use subtle::ConstantTimeEq; use tokio::sync::{RwLock, broadcast}; use tower_http::cors::CorsLayer; use tracing::{debug, error, info, warn}; +use crate::diagnostics::{ + self, BackfillHashesResult, DedupResult as DiagnosticDedupResult, KeepStrategy, + PurgeQualityResult, TimelineBucket, TimelineQuery, +}; use crate::mcp_core::{McpCore, McpTransport, dispatch_mcp_payload}; use crate::rag::{RAGPipeline, SearchOptions, SearchResult, SliceLayer}; use crate::search::{HybridSearchResult, SearchMode}; -use crate::storage::ChromaDocument; +use crate::storage::{ChromaDocument, SchemaMismatchWriteError, SchemaVersion}; + +const DASHBOARD_SESSION_COOKIE: &str = "rust_memex_dashboard_session"; +const DIAGNOSTIC_APPROVAL_TTL: Duration = Duration::from_secs(300); // ============================================================================ // HTML Dashboard (embedded) @@ -109,6 +143,23 @@ const DASHBOARD_HTML: &str = r##" color: var(--text-muted); } .stats-bar strong { color: var(--text); } + .header-actions { + display: flex; + align-items: center; + gap: 12px; + } + .header-actions button { + padding: 9px 14px; + background: transparent; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-muted); + cursor: pointer; + } + .header-actions button:hover { + color: var(--text); + border-color: var(--accent); + } /* Search box */ .search-box { @@ -217,11 +268,15 @@ const DASHBOARD_HTML: &str = r##" transition: border-color 0.2s; } .doc-card:hover { border-color: var(--accent); } + .doc-card.cluster-card { + border-left: 3px solid var(--accent); + } .doc-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; + gap: 12px; } .doc-id { font-family: monospace; @@ -230,6 +285,7 @@ const DASHBOARD_HTML: &str = r##" background: var(--bg); padding: 4px 8px; border-radius: 4px; + overflow-wrap: anywhere; } .doc-score { font-size: 12px; @@ -263,6 +319,7 @@ const DASHBOARD_HTML: &str = r##" margin-top: 12px; display: flex; gap: 8px; + flex-wrap: wrap; } .doc-actions button { padding: 6px 12px; @@ -392,8 +449,11 @@ const DASHBOARD_HTML: &str = r##"

rmcp-memex

-
- Loading... +
+
+ Loading... +
+
@@ -452,6 +512,7 @@ const DASHBOARD_HTML: &str = r##" const API = window.location.origin; let currentNamespace = null; let latestDiscovery = null; + let latestSearchClusters = []; // Initialize document.addEventListener('DOMContentLoaded', async () => { @@ -611,6 +672,7 @@ const DASHBOARD_HTML: &str = r##" const list = document.getElementById('doc-list'); list.innerHTML = '
Searching...
'; + latestSearchClusters = []; const namespace = document.getElementById('namespace-select').value || null; const project = document.getElementById('project-input').value.trim() || null; @@ -631,8 +693,10 @@ const DASHBOARD_HTML: &str = r##" const data = await res.json(); document.getElementById('results-title').textContent = `Search: "${query}"`; + const clusterCount = Array.isArray(data.clusters) ? data.clusters.length : 0; + const duplicateCount = typeof data.duplicate_count === 'number' ? data.duplicate_count : 0; document.getElementById('results-count').textContent = - `${data.count} results in ${data.elapsed_ms}ms`; + `${clusterCount} clusters, ${duplicateCount} hidden duplicates in ${data.elapsed_ms}ms`; if (data.results.length === 0) { list.innerHTML = ` @@ -644,7 +708,10 @@ const DASHBOARD_HTML: &str = r##" return; } - list.innerHTML = data.results.map(doc => renderDocCard(doc, true)).join(''); + latestSearchClusters = Array.isArray(data.clusters) ? data.clusters : []; + list.innerHTML = latestSearchClusters.length > 0 + ? latestSearchClusters.map((cluster, index) => renderClusterCard(cluster, index)).join('') + : data.results.map(doc => renderDocCard(doc, true)).join(''); } catch (e) { list.innerHTML = `

Search failed

@@ -653,6 +720,39 @@ const DASHBOARD_HTML: &str = r##" } } + function renderClusterCard(cluster, index) { + const doc = cluster.representative; + const text = doc.text || ''; + const truncated = text.length > 650 ? text.slice(0, 650) + '...' : text; + const layer = doc.layer || 'flat'; + const label = cluster.source_path || cluster.session_id || doc.id; + + return ` +
+
+ ${escapeHtml(label)} + Score: ${doc.score.toFixed(3)} +
+
${escapeHtml(truncated)}
+
+ Namespace: ${escapeHtml(doc.namespace)} + Grouped by: ${escapeHtml(cluster.group_by)} + Evidence: ${cluster.evidence.length} + Hidden duplicates: ${cluster.hidden_duplicate_count} + ${escapeHtml(layer)} +
+
+ + + + + ${doc.can_expand ? `` : ''} + ${doc.can_drill_up ? `` : ''} +
+
+ `; + } + function renderDocCard(doc, showScore = false) { const text = doc.text || ''; const truncated = text.length > 500 ? text.slice(0, 500) + '...' : text; @@ -680,6 +780,54 @@ const DASHBOARD_HTML: &str = r##" `; } + async function openContextPack(index, view = 'full', showRawEvidence = true) { + const cluster = latestSearchClusters[index]; + if (!cluster) return; + const ids = cluster.evidence.map(item => item.id); + const namespace = cluster.representative.namespace; + document.getElementById('modal-title').textContent = + view === 'decisions' ? 'Decision Context Pack' : 'Context Pack'; + document.getElementById('modal-content').textContent = 'Building context pack...'; + document.getElementById('modal-overlay').classList.add('active'); + + try { + const res = await fetch(`${API}/api/context-pack`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + namespace, + ids, + view, + show_decisions_only: view === 'decisions', + show_raw_evidence: showRawEvidence, + max_evidence_per_cluster: 8, + max_source_chunks: 240 + }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(typeof data === 'string' ? data : JSON.stringify(data)); + document.getElementById('modal-content').textContent = data.markdown || JSON.stringify(data, null, 2); + } catch (e) { + document.getElementById('modal-content').textContent = `Context pack failed: ${e.message}`; + } + } + + function showRawEvidence(index) { + const cluster = latestSearchClusters[index]; + if (!cluster) return; + document.getElementById('modal-title').textContent = 'Raw Evidence'; + document.getElementById('modal-content').textContent = JSON.stringify(cluster.evidence, null, 2); + document.getElementById('modal-overlay').classList.add('active'); + } + + function showClusterDetails(index) { + const cluster = latestSearchClusters[index]; + if (!cluster) return; + document.getElementById('modal-title').textContent = 'Cluster JSON'; + document.getElementById('modal-content').textContent = JSON.stringify(cluster, null, 2); + document.getElementById('modal-overlay').classList.add('active'); + } + function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; @@ -735,6 +883,13 @@ const DASHBOARD_HTML: &str = r##" document.getElementById('modal-overlay').classList.add('active'); } + function logout() { + try { + window.localStorage.removeItem(AUTH_STORAGE_KEY); + } catch (_) {} + window.location.assign(`${API}/auth/logout`); + } + function closeModal(event) { if (!event || event.target.classList.contains('modal-overlay')) { document.getElementById('modal-overlay').classList.remove('active'); @@ -897,8 +1052,269 @@ pub struct HttpState { pub cached_namespaces: Arc>>>, /// Per-namespace last activity timestamp (updated on upsert/index) pub namespace_activity: Arc>>, + /// Last successful append timestamp across all HTTP write paths. + pub last_successful_append_at: Arc>>, + /// Recent destructive diagnostic dry-runs approved for follow-up execute calls. + pub diagnostic_dry_run_approvals: Arc>>, /// Optional Bearer token for authenticating mutating requests pub auth_token: Option, + /// Auth enforcement mode + pub auth_mode: AuthMode, + /// Allow ?token= query parameter on read GETs + pub allow_query_token: bool, + /// Multi-token auth manager (Track C). Used when auth_mode == NamespaceAcl. + pub auth_manager: Option>, + /// Optional dashboard-only OIDC runtime for browser sessions. + dashboard_oidc: Option>, +} + +impl HttpState { + pub fn new(rag: Arc, mcp_core: Arc) -> Self { + Self { + rag, + mcp_core, + mcp_sessions: Arc::new(McpSessionManager::new()), + mcp_base_url: Arc::new(RwLock::new("http://127.0.0.1:0/mcp/messages/".to_string())), + cached_namespaces: Arc::new(RwLock::new(None)), + namespace_activity: Arc::new(RwLock::new(HashMap::new())), + last_successful_append_at: Arc::new(RwLock::new(None)), + diagnostic_dry_run_approvals: Arc::new(RwLock::new(HashMap::new())), + auth_token: None, + auth_mode: AuthMode::MutatingOnly, + allow_query_token: false, + auth_manager: None, + dashboard_oidc: None, + } + } +} + +fn validate_threshold(threshold: u8) -> Result<(), (StatusCode, String)> { + if threshold > 100 { + return Err(( + StatusCode::BAD_REQUEST, + "threshold must be between 0 and 100".to_string(), + )); + } + Ok(()) +} + +fn internal_error(error: anyhow::Error) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) +} + +fn diagnostic_approval_key( + operation: &str, + namespace: Option<&str>, + threshold: Option, +) -> String { + let namespace = namespace.unwrap_or("*"); + let threshold = threshold + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()); + format!("{operation}:{namespace}:{threshold}") +} + +async fn record_diagnostic_dry_run(state: &HttpState, key: String) { + let now = Instant::now(); + let mut approvals = state.diagnostic_dry_run_approvals.write().await; + approvals.retain(|_, recorded_at| now.duration_since(*recorded_at) <= DIAGNOSTIC_APPROVAL_TTL); + approvals.insert(key, now); +} + +async fn consume_diagnostic_dry_run(state: &HttpState, key: &str) -> bool { + let now = Instant::now(); + let mut approvals = state.diagnostic_dry_run_approvals.write().await; + approvals.retain(|_, recorded_at| now.duration_since(*recorded_at) <= DIAGNOSTIC_APPROVAL_TTL); + approvals.remove(key).is_some() +} + +async fn ensure_destructive_diagnostic_allowed( + state: &HttpState, + key: String, + confirm: bool, + allow_single_step: bool, +) -> Result<(), (StatusCode, String)> { + if !confirm { + return Err(( + StatusCode::BAD_REQUEST, + "destructive execution requires confirm=true".to_string(), + )); + } + + if allow_single_step { + return Ok(()); + } + + if consume_diagnostic_dry_run(state, &key).await { + return Ok(()); + } + + Err(( + StatusCode::CONFLICT, + "destructive execution requires a preceding matching dry_run=true call or allow_single_step=true".to_string(), + )) +} + +#[derive(Debug, Clone)] +pub struct DashboardOidcConfig { + pub issuer_url: String, + pub client_id: String, + pub client_secret: Option, + pub public_base_url: Option, + pub scopes: Vec, + pub server_port: u16, +} + +#[derive(Debug, Clone)] +struct ResolvedDashboardOidcConfig { + issuer_url: String, + client_id: String, + client_secret: Option, + public_base_url: String, + redirect_url: String, + scopes: Vec, + secure_cookie: bool, +} + +#[derive(Debug)] +struct PendingDashboardLogin { + pkce_verifier: PkceCodeVerifier, + nonce: Nonce, + created_at: Instant, +} + +#[derive(Debug, Clone)] +struct DashboardSession { + subject: String, + created_at: Instant, +} + +#[derive(Clone)] +struct DashboardOidcRuntime { + config: ResolvedDashboardOidcConfig, + pending_logins: Arc>>, + sessions: Arc>>, +} + +impl DashboardOidcRuntime { + fn new(config: ResolvedDashboardOidcConfig) -> Self { + Self { + config, + pending_logins: Arc::new(RwLock::new(HashMap::new())), + sessions: Arc::new(RwLock::new(HashMap::new())), + } + } + + async fn begin_login(&self) -> anyhow::Result { + let issuer_url = IssuerUrl::new(self.config.issuer_url.clone())?; + let redirect_url = RedirectUrl::new(self.config.redirect_url.clone())?; + let http_client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + let provider_metadata = + CoreProviderMetadata::discover_async(issuer_url, &http_client).await?; + let client = CoreClient::from_provider_metadata( + provider_metadata, + ClientId::new(self.config.client_id.clone()), + self.config.client_secret.clone().map(ClientSecret::new), + ) + .set_redirect_uri(redirect_url); + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + let csrf = CsrfToken::new(uuid::Uuid::new_v4().to_string()); + let nonce = Nonce::new(uuid::Uuid::new_v4().to_string()); + let csrf_for_request = csrf.clone(); + let nonce_for_request = nonce.clone(); + + let mut auth_request = client.authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + move || csrf_for_request.clone(), + move || nonce_for_request.clone(), + ); + for scope in &self.config.scopes { + auth_request = auth_request.add_scope(Scope::new(scope.clone())); + } + let auth_request = auth_request.set_pkce_challenge(pkce_challenge); + let (auth_url, _, _) = auth_request.url(); + + self.pending_logins.write().await.insert( + csrf.secret().clone(), + PendingDashboardLogin { + pkce_verifier, + nonce, + created_at: Instant::now(), + }, + ); + + Ok(auth_url.to_string()) + } + + async fn complete_login(&self, code: String, state: String) -> anyhow::Result { + let pending = self + .pending_logins + .write() + .await + .remove(&state) + .ok_or_else(|| anyhow::anyhow!("OIDC callback state mismatch or expired"))?; + let issuer_url = IssuerUrl::new(self.config.issuer_url.clone())?; + let redirect_url = RedirectUrl::new(self.config.redirect_url.clone())?; + let http_client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + let provider_metadata = + CoreProviderMetadata::discover_async(issuer_url, &http_client).await?; + let client = CoreClient::from_provider_metadata( + provider_metadata, + ClientId::new(self.config.client_id.clone()), + self.config.client_secret.clone().map(ClientSecret::new), + ) + .set_redirect_uri(redirect_url); + let token_response = client + .exchange_code(AuthorizationCode::new(code))? + .set_pkce_verifier(pending.pkce_verifier) + .request_async(&http_client) + .await?; + let id_token = token_response + .id_token() + .ok_or_else(|| anyhow::anyhow!("OIDC provider did not return an ID token"))?; + let claims = id_token.claims(&client.id_token_verifier(), &pending.nonce)?; + + let session_id = uuid::Uuid::new_v4().to_string(); + self.sessions.write().await.insert( + session_id.clone(), + DashboardSession { + subject: claims.subject().to_string(), + created_at: Instant::now(), + }, + ); + Ok(session_id) + } + + async fn has_session(&self, session_id: &str) -> bool { + self.sessions + .read() + .await + .get(session_id) + .map(|session| { + let _ = session.subject.as_str(); + session.created_at.elapsed() < Duration::from_secs(12 * 60 * 60) + }) + .unwrap_or(false) + } + + async fn remove_session(&self, session_id: &str) { + self.sessions.write().await.remove(session_id); + } + + async fn cleanup(&self) { + self.pending_logins + .write() + .await + .retain(|_, login| login.created_at.elapsed() < Duration::from_secs(15 * 60)); + self.sessions + .write() + .await + .retain(|_, session| session.created_at.elapsed() < Duration::from_secs(12 * 60 * 60)); + } } /// Search request body @@ -925,7 +1341,7 @@ fn default_limit() -> usize { } /// Search result for JSON response -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct SearchResultJson { pub id: String, pub namespace: String, @@ -1013,6 +1429,8 @@ impl From for SearchResultJson { #[derive(Debug, Serialize)] pub struct SearchResponse { pub results: Vec, + pub clusters: Vec, + pub duplicate_count: usize, pub query: String, pub namespace: Option, pub elapsed_ms: u64, @@ -1088,6 +1506,105 @@ fn default_mode() -> String { "hybrid".to_string() } +fn default_quality_threshold() -> u8 { + 90 +} + +fn default_timeline_bucket() -> String { + "day".to_string() +} + +fn default_dry_run() -> bool { + true +} + +#[derive(Debug, Deserialize)] +pub struct AuditParams { + pub ns: Option, + #[serde(default = "default_quality_threshold")] + pub threshold: u8, +} + +#[derive(Debug, Deserialize)] +pub struct TimelineParams { + pub ns: Option, + #[serde(default)] + pub since: Option, + #[serde(default)] + pub until: Option, + #[serde(default = "default_timeline_bucket")] + pub bucket: String, +} + +#[derive(Debug, Deserialize)] +pub struct DedupParams { + #[serde(default)] + pub ns: Option, + #[serde(default)] + pub execute: bool, + /// Strategy for grouping chunks into duplicate sets. Defaults to + /// `source-hash-layer` (post-v4 default). Accepts `source-hash-layer`, + /// `source-hash`, or `content-hash` (legacy). Spec P4. + #[serde(default, alias = "groupBy", alias = "group-by")] + pub group_by: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub struct DedupRequest { + #[serde(default)] + pub confirm: bool, + #[serde(default)] + pub allow_single_step: bool, +} + +#[derive(Debug, Deserialize)] +pub struct PurgeQualityRequest { + #[serde(default)] + pub namespace: Option, + #[serde(default = "default_quality_threshold")] + pub threshold: u8, + #[serde(default)] + pub confirm: bool, + #[serde(default = "default_dry_run")] + pub dry_run: bool, + #[serde(default)] + pub allow_single_step: bool, +} + +#[derive(Debug, Serialize)] +pub struct DedupResponse { + pub namespace: Option, + pub execute: bool, + pub dry_run: bool, + pub result: DiagnosticDedupResult, +} + +#[derive(Debug, Deserialize, Default)] +pub struct BackfillHashesParams { + /// Optional namespace filter. Omitted = backfill all namespaces. + #[serde(default)] + pub ns: Option, + /// Default `false` (dry run). Caller must opt in to writes. + #[serde(default)] + pub execute: bool, +} + +#[derive(Debug, Deserialize, Default)] +pub struct BackfillHashesRequest { + #[serde(default)] + pub confirm: bool, + #[serde(default)] + pub allow_single_step: bool, +} + +#[derive(Debug, Serialize)] +pub struct BackfillHashesResponse { + pub namespace: Option, + pub execute: bool, + pub dry_run: bool, + pub result: BackfillHashesResult, +} + fn http_search_mode(mode: &str) -> SearchMode { match mode { "vector" => SearchMode::Vector, @@ -1149,6 +1666,8 @@ pub struct CrossSearchParams { #[derive(Debug, Serialize)] pub struct CrossSearchResponse { pub results: Vec, + pub clusters: Vec, + pub duplicate_count: usize, pub query: String, pub mode: String, pub namespaces_searched: usize, @@ -1162,66 +1681,513 @@ pub struct HealthResponse { pub status: String, pub db_path: String, pub embedding_provider: String, + pub schema_version: String, + pub expected_schema: String, + pub needs_migration: bool, + pub missing_columns: Vec, + pub manifest_version: Option, + pub last_successful_append_at: Option, + pub namespaces: BTreeMap, +} + +#[derive(Debug, Serialize)] +pub struct HealthNamespaceStatus { + pub chunks: usize, + pub last_indexed_at: Option, +} + +/// Extract bearer token from Authorization header or ?token= query param. +fn extract_bearer_token(request: &Request, allow_query_token: bool) -> Option { + // 1. Check Authorization header first + if let Some(header) = request + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + && let Some(token) = header.strip_prefix("Bearer ") + { + return Some(token.to_string()); + } + + // 2. Check ?token= query param if allowed + if allow_query_token && let Some(query) = request.uri().query() { + for pair in query.split('&') { + if let Some(value) = pair.strip_prefix("token=") { + return Some(value.to_string()); + } + } + } + + None +} + +/// Constant-time token comparison to prevent timing attacks. +fn token_matches(provided: &str, expected: &str) -> bool { + let provided_bytes = provided.as_bytes(); + let expected_bytes = expected.as_bytes(); + provided_bytes.ct_eq(expected_bytes).into() +} + +fn cookie_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get(header::COOKIE) + .and_then(|value| value.to_str().ok()) + .and_then(|cookies| { + cookies.split(';').find_map(|cookie| { + let (key, value) = cookie.trim().split_once('=')?; + (key == name).then(|| value.to_string()) + }) + }) +} + +fn dashboard_session_from_request(request: &Request) -> Option { + cookie_value(request.headers(), DASHBOARD_SESSION_COOKIE) +} + +fn route_allows_dashboard_session(method: &Method, path: &str) -> bool { + if path == "/" || path.starts_with("/api/") { + return true; + } + + if *method == Method::POST && path == "/search" { + return true; + } + + if *method == Method::GET + && (path == "/cross-search" + || path.starts_with("/expand/") + || path.starts_with("/parent/") + || path.starts_with("/get/")) + { + return true; + } + + false +} + +fn login_redirect_response() -> axum::response::Response { + ( + StatusCode::SEE_OTHER, + [(header::LOCATION, HeaderValue::from_static("/auth/login"))], + ) + .into_response() +} + +fn unauthorized_response( + request: &Request, + dashboard_oidc_enabled: bool, +) -> axum::response::Response { + let authenticate = [(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"))]; + let dashboard_route = route_allows_dashboard_session(request.method(), request.uri().path()); + + if request.method() == Method::GET && request.uri().path() == "/" { + return if dashboard_oidc_enabled { + login_redirect_response() + } else { + ( + StatusCode::UNAUTHORIZED, + authenticate, + Html(DASHBOARD_LOGIN_HTML.to_string()), + ) + .into_response() + }; + } + + if dashboard_oidc_enabled && dashboard_route { + return ( + StatusCode::UNAUTHORIZED, + authenticate, + Json(json!({"error": "login required", "login_url": "/auth/login"})), + ) + .into_response(); + } + + ( + StatusCode::UNAUTHORIZED, + authenticate, + Json(json!({"error": "missing or invalid auth token"})), + ) + .into_response() } /// Bearer token auth middleware for mutating endpoints. /// If the server has an auth_token configured, requires `Authorization: Bearer `. +/// Uses constant-time comparison to prevent timing side-channel attacks. /// Returns 401 if the token is missing or doesn't match. +/// +/// In NamespaceAcl mode (Track C), delegates to AuthManager for multi-token +/// lookup with scope enforcement. The scope is inferred from the HTTP method: +/// GET/HEAD = Read, POST/PUT/DELETE = Write. async fn auth_middleware( State(state): State, request: Request, next: Next, ) -> impl IntoResponse { - if let Some(ref expected) = state.auth_token { - let auth_header = request - .headers() - .get(axum::http::header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()); - - match auth_header { - Some(header) if header.starts_with("Bearer ") => { - let token = &header[7..]; - if token != expected.as_str() { - return Err(( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "missing or invalid auth token"})), - )); - } + let dashboard_oidc_enabled = state.dashboard_oidc.is_some(); + + if state.auth_mode == AuthMode::AllRoutes { + if let Some(ref expected) = state.auth_token { + let allow_query = state.allow_query_token; + if let Some(token) = extract_bearer_token(&request, allow_query) + && token_matches(&token, expected) + { + return Ok(next.run(request).await); } - _ => { + } + + if route_allows_dashboard_session(request.method(), request.uri().path()) + && let Some(ref oidc) = state.dashboard_oidc + && let Some(session_id) = dashboard_session_from_request(&request) + && oidc.has_session(&session_id).await + { + return Ok(next.run(request).await); + } + } + + // Track C: NamespaceAcl mode uses the AuthManager for multi-token auth + if state.auth_mode == AuthMode::NamespaceAcl + && let Some(ref manager) = state.auth_manager + { + let allow_query = state.allow_query_token; + let bearer = extract_bearer_token(&request, allow_query); + let bearer = match bearer { + Some(t) => t, + None => { + return Err(unauthorized_response(&request, dashboard_oidc_enabled)); + } + }; + + // Determine required scope from method + let required_scope = match *request.method() { + Method::GET | Method::HEAD => crate::auth::Scope::Read, + _ => crate::auth::Scope::Write, + }; + + // Extract namespace from path if present (e.g., /api/browse/{ns}, /ns/{namespace}) + let path = request.uri().path().to_string(); + let namespace = extract_namespace_from_path(&path); + + match manager + .authorize(&bearer, &required_scope, namespace.as_deref()) + .await + { + Ok(_) => {} + Err(crate::auth::AuthDenial::MissingToken | crate::auth::AuthDenial::InvalidToken) => { + return Err(unauthorized_response(&request, dashboard_oidc_enabled)); + } + Err(crate::auth::AuthDenial::Expired { id }) => { return Err(( StatusCode::UNAUTHORIZED, - Json(json!({"error": "missing or invalid auth token"})), - )); + Json(json!({"error": format!("Token '{}' has expired", id)})), + ) + .into_response()); + } + Err( + denial @ (crate::auth::AuthDenial::InsufficientScope { .. } + | crate::auth::AuthDenial::NamespaceDenied { .. }), + ) => { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({"error": denial.to_string()})), + ) + .into_response()); + } + } + + return Ok(next.run(request).await); + } + // Fallback: NamespaceAcl mode without AuthManager configured = same as legacy + + // Legacy single-token path + if let Some(ref expected) = state.auth_token { + let allow_query = state.allow_query_token; + match extract_bearer_token(&request, allow_query) { + Some(token) if token_matches(&token, expected) => {} + _ => { + return Err(unauthorized_response(&request, dashboard_oidc_enabled)); } } } Ok(next.run(request).await) } +/// Extract namespace from URL path segments for ACL checks. +/// Recognizes patterns like: +/// /api/browse/{ns} /ns/{namespace} /expand/{ns}/{id} /get/{ns}/{id} +/// /delete/{ns}/{id} /parent/{ns}/{id} +fn extract_namespace_from_path(path: &str) -> Option { + let segments: Vec<&str> = path.trim_matches('/').split('/').collect(); + match segments.as_slice() { + // /api/browse/{ns} + ["api", "browse", ns] => Some(ns.to_string()), + // /ns/{namespace} + ["ns", ns] => Some(ns.to_string()), + // /expand/{ns}/{id}, /parent/{ns}/{id}, /get/{ns}/{id}, /delete/{ns}/{id} + [verb, ns, _id] if matches!(*verb, "expand" | "parent" | "get" | "delete") => { + Some(ns.to_string()) + } + _ => None, + } +} + +/// Auth enforcement mode for HTTP endpoints. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AuthMode { + /// Bearer required only on mutating + MCP routes (default, backwards compat) + MutatingOnly, + /// Bearer required on ALL routes + AllRoutes, + /// Reserved for Track C namespace-level ACL + NamespaceAcl, +} + +impl AuthMode { + pub fn parse(s: &str) -> Self { + match s { + "all-routes" => Self::AllRoutes, + "namespace-acl" => Self::NamespaceAcl, + _ => Self::MutatingOnly, + } + } +} + +#[derive(Debug, Deserialize)] +struct DashboardOidcCallbackParams { + code: Option, + state: Option, + error: Option, + error_description: Option, +} + +fn http_public_base_url(bind_address: IpAddr, port: u16) -> String { + let host = match bind_address { + IpAddr::V4(addr) if addr.is_unspecified() => std::net::Ipv4Addr::LOCALHOST.to_string(), + IpAddr::V4(addr) => addr.to_string(), + IpAddr::V6(addr) if addr.is_unspecified() => format!("[{}]", std::net::Ipv6Addr::LOCALHOST), + IpAddr::V6(addr) => format!("[{addr}]"), + }; + format!("http://{host}:{port}") +} + +fn resolve_dashboard_oidc_config( + config: &DashboardOidcConfig, + bind_address: IpAddr, +) -> ResolvedDashboardOidcConfig { + let public_base_url = config + .public_base_url + .clone() + .unwrap_or_else(|| http_public_base_url(bind_address, config.server_port)); + let public_base_url = public_base_url.trim_end_matches('/').to_string(); + let redirect_url = format!("{public_base_url}/auth/callback"); + + ResolvedDashboardOidcConfig { + issuer_url: config.issuer_url.clone(), + client_id: config.client_id.clone(), + client_secret: config.client_secret.clone(), + public_base_url: public_base_url.clone(), + redirect_url, + scopes: config.scopes.clone(), + secure_cookie: public_base_url.starts_with("https://"), + } +} + +fn dashboard_session_cookie(session_id: &str, secure: bool) -> String { + let secure_attr = if secure { "; Secure" } else { "" }; + format!( + "{DASHBOARD_SESSION_COOKIE}={session_id}; Path=/; HttpOnly; SameSite=Lax; Max-Age={};{secure_attr}", + 12 * 60 * 60 + ) +} + +fn dashboard_session_cookie_clear(secure: bool) -> String { + let secure_attr = if secure { "; Secure" } else { "" }; + format!( + "{DASHBOARD_SESSION_COOKIE}=deleted; Path=/; HttpOnly; SameSite=Lax; Max-Age=0;{secure_attr}" + ) +} + +fn html_error_page(title: &str, message: &str, status: StatusCode) -> axum::response::Response { + let body = format!( + "{title}

{title}

{message}

Return to dashboard

" + ); + (status, Html(body)).into_response() +} + +async fn dashboard_oidc_login_handler( + State(state): State, + request: Request, +) -> axum::response::Response { + let Some(oidc) = state.dashboard_oidc.clone() else { + return StatusCode::NOT_FOUND.into_response(); + }; + + if let Some(session_id) = dashboard_session_from_request(&request) + && oidc.has_session(&session_id).await + { + return ( + StatusCode::SEE_OTHER, + [(header::LOCATION, HeaderValue::from_static("/"))], + ) + .into_response(); + } + + match oidc.begin_login().await { + Ok(auth_url) => match HeaderValue::from_str(&auth_url) { + Ok(location) => (StatusCode::SEE_OTHER, [(header::LOCATION, location)]).into_response(), + Err(_) => html_error_page( + "OIDC Login Error", + "Provider login URL contained invalid header characters.", + StatusCode::INTERNAL_SERVER_ERROR, + ), + }, + Err(err) => { + error!("OIDC login bootstrap failed: {err}"); + html_error_page( + "OIDC Login Error", + "rust-memex could not start the dashboard login flow.", + StatusCode::INTERNAL_SERVER_ERROR, + ) + } + } +} + +async fn dashboard_oidc_callback_handler( + State(state): State, + Query(params): Query, +) -> axum::response::Response { + let Some(oidc) = state.dashboard_oidc.clone() else { + return StatusCode::NOT_FOUND.into_response(); + }; + + if let Some(error_code) = params.error { + let description = params + .error_description + .unwrap_or_else(|| "Provider returned an authentication error.".to_string()); + return html_error_page( + "OIDC Login Rejected", + &format!("{error_code}: {description}"), + StatusCode::BAD_REQUEST, + ); + } + + let Some(code) = params.code else { + return html_error_page( + "OIDC Callback Error", + "Missing authorization code in callback.", + StatusCode::BAD_REQUEST, + ); + }; + let Some(callback_state) = params.state else { + return html_error_page( + "OIDC Callback Error", + "Missing callback state parameter.", + StatusCode::BAD_REQUEST, + ); + }; + + match oidc.complete_login(code, callback_state).await { + Ok(session_id) => match HeaderValue::from_str(&dashboard_session_cookie( + &session_id, + oidc.config.secure_cookie, + )) { + Ok(cookie_value) => ( + StatusCode::SEE_OTHER, + [ + (header::LOCATION, HeaderValue::from_static("/")), + (header::SET_COOKIE, cookie_value), + ], + ) + .into_response(), + Err(_) => html_error_page( + "OIDC Session Error", + "Dashboard session cookie could not be created.", + StatusCode::INTERNAL_SERVER_ERROR, + ), + }, + Err(err) => { + error!("OIDC callback failed: {err}"); + html_error_page( + "OIDC Session Error", + "rust-memex could not finish the dashboard login flow.", + StatusCode::BAD_GATEWAY, + ) + } + } +} + +async fn dashboard_oidc_logout_handler( + State(state): State, + request: Request, +) -> axum::response::Response { + let secure_cookie = state + .dashboard_oidc + .as_ref() + .map(|oidc| oidc.config.secure_cookie) + .unwrap_or(false); + + if let Some(ref oidc) = state.dashboard_oidc + && let Some(session_id) = dashboard_session_from_request(&request) + { + oidc.remove_session(&session_id).await; + } + + match HeaderValue::from_str(&dashboard_session_cookie_clear(secure_cookie)) { + Ok(cookie_value) => ( + StatusCode::SEE_OTHER, + [ + (header::LOCATION, HeaderValue::from_static("/")), + (header::SET_COOKIE, cookie_value), + ], + ) + .into_response(), + Err(_) => ( + StatusCode::SEE_OTHER, + [(header::LOCATION, HeaderValue::from_static("/"))], + ) + .into_response(), + } +} + /// HTTP server configuration passed to `create_router` and `start_server` #[derive(Clone)] pub struct HttpServerConfig { - /// Bearer token for auth on mutating endpoints. None = no auth. + /// Bearer token for auth on HTTP endpoints. None = no auth. pub auth_token: Option, + /// Optional dashboard-only OIDC configuration. + pub dashboard_oidc: Option, /// Allowed CORS origins. Empty = same-origin only (unless localhost). pub cors_origins: Vec, /// Bind address. Defaults to 127.0.0.1. pub bind_address: IpAddr, + /// Auth enforcement mode + pub auth_mode: AuthMode, + /// Allow ?token= query param on read GETs (only in all-routes mode) + pub allow_query_token: bool, + /// Multi-token auth manager (Track C). Used when auth_mode == NamespaceAcl. + pub auth_manager: Option>, } impl Default for HttpServerConfig { fn default() -> Self { Self { auth_token: None, + dashboard_oidc: None, cors_origins: Vec::new(), bind_address: std::net::Ipv4Addr::LOCALHOST.into(), + auth_mode: AuthMode::MutatingOnly, + allow_query_token: false, + auth_manager: None, } } } /// Create the HTTP router pub fn create_router(state: HttpState, config: &HttpServerConfig) -> Router { + let mut state = state; + state.auth_token = config.auth_token.clone(); + state.auth_mode = config.auth_mode.clone(); + state.allow_query_token = config.allow_query_token; + state.auth_manager = config.auth_manager.clone(); + let is_localhost = config.bind_address.is_loopback(); // CORS policy: permissive on localhost, restrictive otherwise @@ -1239,6 +2205,12 @@ pub fn create_router(state: HttpState, config: &HttpServerConfig) -> Router { axum::http::header::CONTENT_TYPE, axum::http::header::AUTHORIZATION, ]) + } else if config.cors_origins.iter().any(|o| o == "*") { + // Explicit wildcard: use tower_http::cors::Any instead of literal "*" string + CorsLayer::new() + .allow_origin(tower_http::cors::Any) + .allow_methods(tower_http::cors::Any) + .allow_headers(tower_http::cors::Any) } else { // Explicit origins configured let origins: Vec = config @@ -1255,9 +2227,13 @@ pub fn create_router(state: HttpState, config: &HttpServerConfig) -> Router { ]) }; - // Read-only routes (no auth required) - let public_routes = Router::new() + let all_routes_auth = config.auth_mode == AuthMode::AllRoutes; + + // Read-only routes: public in mutating-only mode, authed in all-routes mode. + // The dashboard route returns an auth bootstrap page when bearer is missing. + let read_routes = Router::new() .route("/", get(dashboard_handler)) + .route("/health", get(health_handler)) .route("/api/discovery", get(discovery_handler)) .route("/api/namespaces", get(namespaces_handler)) .route("/api/overview", get(overview_handler)) @@ -1265,24 +2241,40 @@ pub fn create_router(state: HttpState, config: &HttpServerConfig) -> Router { .route("/api/browse", get(browse_all_handler)) .route("/api/browse/", get(browse_all_handler)) .route("/api/browse/{ns}", get(browse_handler)) - .route("/health", get(health_handler)) .route("/search", post(search_handler)) .route("/sse/search", get(sse_search_handler)) .route("/cross-search", get(cross_search_handler)) .route("/sse/cross-search", get(sse_cross_search_handler)) .route("/sse/namespaces", get(sse_namespaces_handler)) + .route( + "/api/context-pack", + post(context_pack::context_pack_handler), + ) .route("/expand/{ns}/{id}", get(expand_handler)) .route("/parent/{ns}/{id}", get(parent_handler)) - .route("/get/{ns}/{id}", get(get_handler)); + .route("/get/{ns}/{id}", get(get_handler)) + .merge(diagnostic_routes()); + + // Conditionally wrap read routes with auth middleware in all-routes mode + let read_routes = if all_routes_auth { + read_routes.route_layer(middleware::from_fn_with_state( + state.clone(), + auth_middleware, + )) + } else { + read_routes + }; // Mutating routes (auth required when token is configured) let authed_routes = Router::new() .route("/refresh", post(refresh_handler)) - .route("/sse/optimize", post(sse_optimize_handler)) .route("/upsert", post(upsert_handler)) .route("/index", post(index_handler)) .route("/delete/{ns}/{id}", post(delete_handler)) .route("/ns/{namespace}", delete(purge_namespace_handler)) + .merge(diagnostic_authed_routes()) + .merge(lifecycle_routes()) + .merge(recovery_routes()) .route_layer(middleware::from_fn_with_state( state.clone(), auth_middleware, @@ -1291,28 +2283,134 @@ pub fn create_router(state: HttpState, config: &HttpServerConfig) -> Router { // MCP-over-SSE endpoints (auth required when token is configured) let mcp_routes = Router::new() .route("/mcp/", get(mcp_sse_handler)) + .route("/mcp", get(mcp_sse_handler)) .route("/mcp/messages/", post(mcp_messages_handler)) + .route("/mcp/messages", post(mcp_messages_handler)) .route("/sse/", get(mcp_sse_handler)) + .route("/sse", get(mcp_sse_handler)) .route("/messages/", post(mcp_messages_handler)) + .route("/messages", post(mcp_messages_handler)) .route_layer(middleware::from_fn_with_state( state.clone(), auth_middleware, )); + let public_routes = Router::new() + .route("/auth/login", get(dashboard_oidc_login_handler)) + .route("/auth/callback", get(dashboard_oidc_callback_handler)) + .route("/auth/logout", get(dashboard_oidc_logout_handler)); + public_routes + .merge(read_routes) .merge(authed_routes) .merge(mcp_routes) .layer(cors) .with_state(state) } +fn lifecycle_routes() -> Router { + lifecycle::routes() +} + +fn recovery_routes() -> Router { + recovery::routes() +} + +fn diagnostic_routes() -> Router { + Router::new() + .route("/api/audit", get(audit_handler)) + .route("/api/stats", get(database_stats_handler)) + .route("/api/stats/{ns}", get(namespace_stats_handler)) + .route("/api/timeline", get(timeline_handler)) +} + +fn diagnostic_authed_routes() -> Router { + Router::new() + .route("/api/purge-quality", post(purge_quality_handler)) + .route("/api/dedup", post(dedup_handler)) + .route("/api/backfill-hashes", post(backfill_hashes_handler)) +} + /// Health check endpoint async fn health_handler(State(state): State) -> impl IntoResponse { - Json(HealthResponse { - status: "ok".to_string(), + Json(build_health_response(&state).await) +} + +async fn build_health_response(state: &HttpState) -> HealthResponse { + let expected_schema = SchemaVersion::current(); + let schema_status = state + .rag + .storage_manager() + .schema_status(expected_schema) + .await + .ok(); + let namespace_counts = state + .rag + .storage_manager() + .list_namespaces() + .await + .unwrap_or_default(); + let activity = state.namespace_activity.read().await; + let namespaces = namespace_counts + .into_iter() + .map(|(namespace, chunks)| { + ( + namespace.clone(), + HealthNamespaceStatus { + chunks, + last_indexed_at: activity.get(&namespace).cloned(), + }, + ) + }) + .collect::>(); + let last_successful_append_at = state.last_successful_append_at.read().await.clone(); + + let (schema_version, expected_schema, needs_migration, missing_columns, manifest_version) = + schema_status + .map(|status| { + ( + health_schema_version_label(status.schema_version, status.needs_migration), + status.expected_schema.to_string(), + status.needs_migration, + status.missing_columns, + status.manifest_version, + ) + }) + .unwrap_or_else(|| { + ( + "unknown".to_string(), + expected_schema.to_string(), + false, + Vec::new(), + None, + ) + }); + + HealthResponse { + status: if needs_migration { + "needs_migration" + } else { + "ok" + } + .to_string(), db_path: state.rag.storage_manager().lance_path().to_string(), embedding_provider: state.rag.mlx_connected_to(), - }) + schema_version, + expected_schema, + needs_migration, + missing_columns, + manifest_version, + last_successful_append_at, + namespaces, + } +} + +fn health_schema_version_label(version: SchemaVersion, needs_migration: bool) -> String { + if needs_migration && matches!(version, SchemaVersion::V3) { + "v3-pre".to_string() + } else { + version.to_string() + } } // ============================================================================ @@ -1400,11 +2498,13 @@ async fn refresh_namespace_cache(state: &HttpState) -> anyhow::Result<()> { } async fn mark_namespace_activity(state: &HttpState, namespace: &str) { + let now = chrono::Utc::now().to_rfc3339(); state .namespace_activity .write() .await - .insert(namespace.to_string(), chrono::Utc::now().to_rfc3339()); + .insert(namespace.to_string(), now.clone()); + *state.last_successful_append_at.write().await = Some(now); if let Err(error) = refresh_namespace_cache(state).await { warn!( "Namespace cache refresh failed after activity update: {}", @@ -1412,7 +2512,6 @@ async fn mark_namespace_activity(state: &HttpState, namespace: &str) { ); } } - fn namespaces_response_from_discovery(discovery: &DiscoveryResponse) -> NamespacesResponse { NamespacesResponse { total: discovery.namespaces.len(), @@ -1427,48 +2526,358 @@ fn namespaces_response_from_discovery(discovery: &DiscoveryResponse) -> Namespac } } -fn overview_response_from_discovery(discovery: &DiscoveryResponse) -> OverviewResponse { - OverviewResponse { - namespace_count: discovery.namespace_count, - total_documents: discovery.total_documents, - db_path: discovery.db_path.clone(), - embedding_provider: discovery.embedding_provider.clone(), +fn overview_response_from_discovery(discovery: &DiscoveryResponse) -> OverviewResponse { + OverviewResponse { + namespace_count: discovery.namespace_count, + total_documents: discovery.total_documents, + db_path: discovery.db_path.clone(), + embedding_provider: discovery.embedding_provider.clone(), + } +} + +fn status_response_from_discovery(discovery: &DiscoveryResponse) -> serde_json::Value { + json!({ + "cache_ready": discovery.status == "ok", + "namespace_count": discovery.namespace_count, + "hint": discovery.hint, + }) +} + +/// Dashboard HTML endpoint (GET /) +/// Minimal HTML login form for dashboard auth in all-routes mode. +/// Token is stored in localStorage and used as Bearer header for all API calls. +const DASHBOARD_LOGIN_HTML: &str = r##" + + + + +rust-memex - Login Required + + + +
+

rust-memex Dashboard

+

This server requires authentication. Enter your bearer token to continue.

+
Invalid token. Please try again.
+
+ + +
+
+ + +"##; + +async fn dashboard_handler(State(state): State, request: Request) -> impl IntoResponse { + debug!("Dashboard: serving HTML"); + + // In all-routes mode without OIDC, check if the user has a valid bearer token. + if state.auth_mode == AuthMode::AllRoutes + && state.dashboard_oidc.is_none() + && let Some(ref expected) = state.auth_token + { + let allow_query = state.allow_query_token; + let has_valid_token = match extract_bearer_token(&request, allow_query) { + Some(token) => token_matches(&token, expected), + None => false, + }; + + if !has_valid_token { + return Html(DASHBOARD_LOGIN_HTML.to_string()); + } + } + + Html(get_dashboard_html()) +} + +/// List all namespaces with document counts (GET /api/namespaces) +async fn namespaces_handler(State(state): State) -> Json { + Json(namespaces_response_from_discovery( + &build_discovery_response(&state).await, + )) +} + +/// Database overview (GET /api/overview) +async fn overview_handler(State(state): State) -> Json { + Json(overview_response_from_discovery( + &build_discovery_response(&state).await, + )) +} + +/// System status including cache state (GET /api/status) +async fn status_handler(State(state): State) -> Json { + Json(status_response_from_discovery( + &build_discovery_response(&state).await, + )) +} + +/// Diagnostic audit for a single namespace (GET /api/audit) +async fn audit_handler( + State(state): State, + Query(params): Query, +) -> Result, (StatusCode, String)> { + validate_threshold(params.threshold)?; + let namespace = params + .ns + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + "ns query parameter is required".to_string(), + ) + })?; + + let result = diagnostics::audit_namespaces( + state.rag.storage_manager().as_ref(), + Some(namespace), + params.threshold, + ) + .await + .map_err(internal_error)? + .into_iter() + .next() + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + format!("namespace '{namespace}' not found"), + ) + })?; + + Ok(Json(result)) +} + +/// Database statistics (GET /api/stats) +async fn database_stats_handler( + State(state): State, +) -> Result, (StatusCode, String)> { + let stats = diagnostics::database_stats(state.rag.storage_manager().as_ref()) + .await + .map_err(internal_error)?; + Ok(Json(stats)) +} + +/// Namespace statistics (GET /api/stats/{ns}) +async fn namespace_stats_handler( + State(state): State, + Path(ns): Path, +) -> Result, (StatusCode, String)> { + let stats = diagnostics::namespace_stats(state.rag.storage_manager().as_ref(), Some(&ns)) + .await + .map_err(internal_error)?; + let result = stats + .into_iter() + .next() + .ok_or_else(|| (StatusCode::NOT_FOUND, format!("namespace '{ns}' not found")))?; + Ok(Json(result)) +} + +/// Time-bucketed timeline (GET /api/timeline) +async fn timeline_handler( + State(state): State, + Query(params): Query, +) -> Result>, (StatusCode, String)> { + let bucket = match params.bucket.as_str() { + "day" => TimelineBucket::Day, + "hour" => TimelineBucket::Hour, + _ => { + return Err(( + StatusCode::BAD_REQUEST, + "bucket must be 'day' or 'hour'".to_string(), + )); + } + }; + + let report = diagnostics::timeline_report( + state.rag.storage_manager().as_ref(), + &TimelineQuery { + namespace: params.ns, + since: params.since, + until: params.until, + bucket, + }, + ) + .await + .map_err(internal_error)?; + + Ok(Json(report.entries)) +} + +/// Purge low-quality namespaces, gated by prior dry-run unless explicitly single-step. +async fn purge_quality_handler( + State(state): State, + Json(request): Json, +) -> Result, (StatusCode, String)> { + validate_threshold(request.threshold)?; + let key = diagnostic_approval_key( + "purge-quality", + request.namespace.as_deref(), + Some(request.threshold), + ); + + if request.dry_run { + let result = diagnostics::purge_quality_namespaces( + state.rag.storage_manager().as_ref(), + request.namespace.as_deref(), + request.threshold, + true, + ) + .await + .map_err(internal_error)?; + record_diagnostic_dry_run(&state, key).await; + return Ok(Json(result)); + } + + ensure_destructive_diagnostic_allowed(&state, key, request.confirm, request.allow_single_step) + .await?; + + let result = diagnostics::purge_quality_namespaces( + state.rag.storage_manager().as_ref(), + request.namespace.as_deref(), + request.threshold, + false, + ) + .await + .map_err(internal_error)?; + + Ok(Json(result)) +} + +/// Deduplicate a namespace, dry-run by default and gated on execute. +async fn dedup_handler( + State(state): State, + Query(params): Query, + body: String, +) -> Result, (StatusCode, String)> { + let request = if body.trim().is_empty() { + DedupRequest::default() + } else { + serde_json::from_str::(&body).map_err(|error| { + ( + StatusCode::BAD_REQUEST, + format!("invalid dedup request body: {error}"), + ) + })? + }; + + let dry_run = !params.execute; + let key = diagnostic_approval_key("dedup", params.ns.as_deref(), None); + + if !dry_run { + ensure_destructive_diagnostic_allowed( + &state, + key.clone(), + request.confirm, + request.allow_single_step, + ) + .await?; + } + + let group_by = params + .group_by + .as_deref() + .map(diagnostics::DedupGroupBy::parse) + .unwrap_or_default(); + + let result = diagnostics::deduplicate_documents( + state.rag.storage_manager().as_ref(), + params.ns.as_deref(), + dry_run, + KeepStrategy::Oldest, + false, + group_by, + ) + .await + .map_err(internal_error)?; + + if dry_run { + record_diagnostic_dry_run(&state, key).await; } -} -fn status_response_from_discovery(discovery: &DiscoveryResponse) -> serde_json::Value { - json!({ - "cache_ready": discovery.status == "ok", - "namespace_count": discovery.namespace_count, - "hint": discovery.hint, - }) + Ok(Json(DedupResponse { + namespace: params.ns, + execute: params.execute, + dry_run, + result, + })) } -/// Dashboard HTML endpoint (GET /) -async fn dashboard_handler() -> Html { - debug!("Dashboard: serving HTML"); - Html(get_dashboard_html()) -} +/// Backfill `content_hash` (per-chunk) and `source_hash` (per-source) for +/// chunks written before the v4 schema. Behaves like `dedup`: dry-run by +/// default, requires explicit confirmation for the destructive path. Spec: +/// 2026-04-27 onion-slicer fix, P0 backfill. +async fn backfill_hashes_handler( + State(state): State, + Query(params): Query, + body: String, +) -> Result, (StatusCode, String)> { + let request = if body.trim().is_empty() { + BackfillHashesRequest::default() + } else { + serde_json::from_str::(&body).map_err(|error| { + ( + StatusCode::BAD_REQUEST, + format!("invalid backfill-hashes request body: {error}"), + ) + })? + }; -/// List all namespaces with document counts (GET /api/namespaces) -async fn namespaces_handler(State(state): State) -> Json { - Json(namespaces_response_from_discovery( - &build_discovery_response(&state).await, - )) -} + let dry_run = !params.execute; + let key = diagnostic_approval_key("backfill-hashes", params.ns.as_deref(), None); -/// Database overview (GET /api/overview) -async fn overview_handler(State(state): State) -> Json { - Json(overview_response_from_discovery( - &build_discovery_response(&state).await, - )) -} + if !dry_run { + ensure_destructive_diagnostic_allowed( + &state, + key.clone(), + request.confirm, + request.allow_single_step, + ) + .await?; + } -/// System status including cache state (GET /api/status) -async fn status_handler(State(state): State) -> Json { - Json(status_response_from_discovery( - &build_discovery_response(&state).await, - )) + let result = diagnostics::backfill_chunk_and_source_hashes( + state.rag.storage_manager().as_ref(), + params.ns.as_deref(), + dry_run, + ) + .await + .map_err(internal_error)?; + + if dry_run { + record_diagnostic_dry_run(&state, key).await; + } + + Ok(Json(BackfillHashesResponse { + namespace: params.ns, + execute: params.execute, + dry_run, + result, + })) } /// Browse documents in namespace (GET /api/browse/:ns) @@ -1593,9 +3002,13 @@ async fn search_handler( })?; let count = results.len(); + let clusters = context_pack::collapse_results(&results); + let duplicate_count = count.saturating_sub(clusters.len()); Ok(Json(SearchResponse { results, + clusters, + duplicate_count, query: req.query, namespace: req.namespace, elapsed_ms: start.elapsed().as_millis() as u64, @@ -1698,6 +3111,8 @@ async fn cross_search_handler( if namespaces.is_empty() { return Ok(Json(CrossSearchResponse { results: vec![], + clusters: vec![], + duplicate_count: 0, query: params.query, mode: params.mode, namespaces_searched: 0, @@ -1741,9 +3156,13 @@ async fn cross_search_handler( let results: Vec = all_results.into_iter().map(|(r, _)| r).collect(); let total_results = results.len(); + let clusters = context_pack::collapse_results(&results); + let duplicate_count = total_results.saturating_sub(clusters.len()); Ok(Json(CrossSearchResponse { results, + clusters, + duplicate_count, query: params.query, mode: params.mode, namespaces_searched: namespaces_count, @@ -1972,125 +3391,20 @@ async fn sse_namespaces_handler( ) } -/// SSE streaming optimize endpoint - runs compact + prune with progress events -/// POST /sse/optimize - streams optimization progress and stats -async fn sse_optimize_handler( - State(state): State, -) -> Sse>> { - let stream = async_stream::stream! { - let start = std::time::Instant::now(); - - // Pre-optimize stats - let pre_stats = state.rag.storage_manager().stats().await.ok(); - - yield Ok(Event::default() - .event("start") - .data(serde_json::json!({ - "status": "starting_optimization", - "db_path": state.rag.storage_manager().lance_path(), - "pre_row_count": pre_stats.as_ref().map(|s| s.row_count), - "pre_version_count": pre_stats.as_ref().map(|s| s.version_count), - }).to_string())); - - // Phase 1: Compact - yield Ok(Event::default() - .event("phase") - .data(serde_json::json!({ - "phase": "compact", - "status": "running", - "description": "Merging small files into larger ones" - }).to_string())); - - let compact_result = state.rag.storage_manager().compact().await; - - match &compact_result { - Ok(stats) => { - yield Ok(Event::default() - .event("compact_done") - .data(serde_json::json!({ - "phase": "compact", - "status": "complete", - "files_removed": stats.compaction.as_ref().map(|c| c.files_removed), - "files_added": stats.compaction.as_ref().map(|c| c.files_added), - "fragments_removed": stats.compaction.as_ref().map(|c| c.fragments_removed), - "fragments_added": stats.compaction.as_ref().map(|c| c.fragments_added), - }).to_string())); - } - Err(e) => { - yield Ok(Event::default() - .event("compact_error") - .data(serde_json::json!({ - "phase": "compact", - "status": "error", - "error": e.to_string() - }).to_string())); - } - } - - tokio::time::sleep(Duration::from_millis(10)).await; - - // Phase 2: Prune old versions - yield Ok(Event::default() - .event("phase") - .data(serde_json::json!({ - "phase": "prune", - "status": "running", - "description": "Removing old versions (>7 days)" - }).to_string())); - - let prune_result = state.rag.storage_manager().cleanup(Some(7)).await; - - match &prune_result { - Ok(stats) => { - yield Ok(Event::default() - .event("prune_done") - .data(serde_json::json!({ - "phase": "prune", - "status": "complete", - "old_versions": stats.prune.as_ref().map(|p| p.old_versions), - "bytes_removed": stats.prune.as_ref().map(|p| p.bytes_removed), - }).to_string())); - } - Err(e) => { - yield Ok(Event::default() - .event("prune_error") - .data(serde_json::json!({ - "phase": "prune", - "status": "error", - "error": e.to_string() - }).to_string())); - } - } - - // Post-optimize stats - let post_stats = state.rag.storage_manager().stats().await.ok(); - - yield Ok(Event::default() - .event("done") - .data(serde_json::json!({ - "status": "complete", - "post_row_count": post_stats.as_ref().map(|s| s.row_count), - "post_version_count": post_stats.as_ref().map(|s| s.version_count), - "compact_ok": compact_result.is_ok(), - "prune_ok": prune_result.is_ok(), - "elapsed_ms": start.elapsed().as_millis() as u64 - }).to_string())); - }; - - Sse::new(stream).keep_alive( - axum::response::sse::KeepAlive::new() - .interval(Duration::from_secs(15)) - .text("ping"), - ) -} - /// Upsert document endpoint (POST /upsert) - uses memory_upsert async fn upsert_handler( State(state): State, Json(req): Json, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, Json)> { let metadata = req.metadata.unwrap_or(serde_json::json!({})); + state + .rag + .storage_manager() + .require_current_schema_for_writes() + .await + .map_err(|e| write_error_response("upsert", &req.namespace, e))?; + state .rag .memory_upsert( @@ -2100,10 +3414,7 @@ async fn upsert_handler( metadata, ) .await - .map_err(|e| { - error!("Upsert error: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) - })?; + .map_err(|e| write_error_response("upsert", &req.namespace, e))?; mark_namespace_activity(&state, &req.namespace).await; @@ -2118,7 +3429,7 @@ async fn upsert_handler( async fn index_handler( State(state): State, Json(req): Json, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, Json)> { use crate::rag::SliceMode; let mode = match req.slice_mode.as_str() { @@ -2127,6 +3438,13 @@ async fn index_handler( _ => SliceMode::Flat, }; + state + .rag + .storage_manager() + .require_current_schema_for_writes() + .await + .map_err(|e| write_error_response("index", &req.namespace, e))?; + // Generate ID from content hash let id = format!( "idx_{}", @@ -2147,10 +3465,7 @@ async fn index_handler( mode, ) .await - .map_err(|e| { - error!("Index error: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) - })?; + .map_err(|e| write_error_response("index", &req.namespace, e))?; mark_namespace_activity(&state, &req.namespace).await; @@ -2162,6 +3477,52 @@ async fn index_handler( }))) } +fn write_error_response( + operation: &str, + namespace: &str, + error: anyhow::Error, +) -> (StatusCode, Json) { + if let Some(schema_error) = error.downcast_ref::() { + let remediation = schema_error.remediation(); + error!( + error_kind = "schema_mismatch", + operation = %operation, + namespace = %namespace, + db_path = %schema_error.db_path(), + missing_columns = ?schema_error.missing_columns(), + remediation = %remediation, + file = file!(), + line = line!(), + "HTTP write failed due to schema mismatch" + ); + return ( + StatusCode::PRECONDITION_FAILED, + Json(json!({ + "error": "schema_mismatch", + "error_kind": "schema_mismatch", + "missing_columns": schema_error.missing_columns(), + "remediation": remediation, + })), + ); + } + + error!( + operation = %operation, + namespace = %namespace, + file = file!(), + line = line!(), + "HTTP write failed: {error}" + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": "internal", + "error_kind": "internal", + "message": error.to_string(), + })), + ) +} + /// Expand onion slice - get children (GET /expand/:ns/:id) async fn expand_handler( State(state): State, @@ -2288,12 +3649,13 @@ pub struct McpMessagesParams { /// Creates a new session and sends the endpoint URL for messages async fn mcp_sse_handler( State(state): State, + uri: axum::http::Uri, headers: axum::http::HeaderMap, ) -> Sse>> { // Create a new session let (session_id, mut rx) = state.mcp_sessions.create_session().await; - // Use Host header from request to build endpoint URL (enables remote access) + // Use Host header from request to build base URL (enables remote access in logs) let base_url = if let Some(host) = headers.get(axum::http::header::HOST) { if let Ok(host_str) = host.to_str() { format!("http://{}", host_str) @@ -2305,16 +3667,21 @@ async fn mcp_sse_handler( }; info!( - "MCP SSE: New session {} (base_url: {})", - session_id, base_url + "MCP SSE: New session {} (base_url: {}, path: {})", + session_id, + base_url, + uri.path() ); let sessions_for_cleanup = state.mcp_sessions.clone(); let session_id_for_cleanup = session_id.clone(); + let path_str = uri.path().to_string(); let stream = async_stream::stream! { - // First event: tell client where to POST messages (FastMCP/MCP SSE protocol) - let endpoint_url = format!("{}/messages/?session_id={}", base_url, session_id); + // Build a relative path for the client POST messages (FastMCP/MCP SSE standard) + let is_mcp = path_str.starts_with("/mcp"); + let relative_path = if is_mcp { "/mcp/messages/" } else { "/messages/" }; + let endpoint_url = format!("{}?session_id={}", relative_path, session_id); yield Ok(Event::default() .event("endpoint") .data(endpoint_url)); @@ -2421,21 +3788,65 @@ pub async fn start_server( // Fallback base_url - actual URL is derived from Host header in mcp_sse_handler let base_url = format!("http://{}:{}", server_config.bind_address, port); let cached_namespaces = Arc::new(RwLock::new(None)); + let dashboard_oidc = server_config.dashboard_oidc.as_ref().map(|config| { + Arc::new(DashboardOidcRuntime::new(resolve_dashboard_oidc_config( + config, + server_config.bind_address, + ))) + }); // Log auth status if server_config.auth_token.is_some() { - info!("HTTP auth: Bearer token required for mutating endpoints"); + let mode_label = match server_config.auth_mode { + AuthMode::MutatingOnly => "mutating endpoints only", + AuthMode::AllRoutes => "ALL routes", + AuthMode::NamespaceAcl => "namespace ACL (Track C)", + }; + info!("HTTP auth: Bearer token required for {}", mode_label); + if server_config.allow_query_token { + info!("HTTP auth: ?token= query parameter enabled for read GETs"); + } + if let Some(ref oidc) = dashboard_oidc { + info!( + "Dashboard auth: OIDC enabled at {} (callback: {})", + oidc.config.public_base_url, oidc.config.redirect_url + ); + } } else { warn!( "WARNING: HTTP server running without auth token. Set MEMEX_AUTH_TOKEN or use --auth-token." ); } - // Warn if exposed on network without auth - if !server_config.bind_address.is_loopback() && server_config.auth_token.is_none() { - warn!( - "WARNING: HTTP server exposed on network without auth token. Set MEMEX_AUTH_TOKEN or use --auth-token." - ); + // Log namespace security status. Track C: auth manager is always present; + // "enabled" means at least one token is configured. + let auth_mgr = mcp_core.auth_manager(); + if auth_mgr.has_any_tokens().await { + let tokens = auth_mgr.list_tokens().await; + let protected_namespaces: std::collections::BTreeSet = tokens + .iter() + .flat_map(|entry| entry.namespaces.iter().cloned()) + .filter(|ns| ns != "*") + .collect(); + if protected_namespaces.is_empty() { + warn!( + "Namespace security enabled but NO per-namespace tokens configured (wildcard-only). All namespaces are covered by wildcard tokens." + ); + } else { + info!( + "Namespace security: {} namespace(s) with tokens:", + protected_namespaces.len() + ); + for ns_name in &protected_namespaces { + // Pick any token that references this namespace for its description. + let desc = tokens + .iter() + .find(|entry| entry.namespaces.iter().any(|ns| ns == ns_name)) + .map(|entry| entry.description.as_str()) + .unwrap_or("(no description)"); + info!(" - '{}' {}", ns_name, desc); + } + } } let state = HttpState { @@ -2445,7 +3856,13 @@ pub async fn start_server( mcp_base_url: Arc::new(RwLock::new(base_url.clone())), cached_namespaces: cached_namespaces.clone(), namespace_activity: Arc::new(RwLock::new(HashMap::new())), + last_successful_append_at: Arc::new(RwLock::new(None)), + diagnostic_dry_run_approvals: Arc::new(RwLock::new(HashMap::new())), auth_token: server_config.auth_token.clone(), + auth_mode: server_config.auth_mode.clone(), + allow_query_token: server_config.allow_query_token, + auth_manager: server_config.auth_manager.clone(), + dashboard_oidc: dashboard_oidc.clone(), }; // Spawn background task to refresh namespace cache every 5 minutes @@ -2516,12 +3933,16 @@ pub async fn start_server( // Spawn background task to reap stale MCP sessions every 5 minutes let bg_sessions = state.mcp_sessions.clone(); + let bg_dashboard_oidc = state.dashboard_oidc.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(300)); interval.tick().await; // skip first immediate tick loop { interval.tick().await; bg_sessions.cleanup_old_sessions().await; + if let Some(ref oidc) = bg_dashboard_oidc { + oidc.cleanup().await; + } } }); @@ -2544,13 +3965,11 @@ pub async fn start_server( #[cfg(test)] mod tests { use super::*; - use crate::{ - embeddings::EmbeddingClient, - security::{NamespaceAccessManager, NamespaceSecurityConfig}, - storage::StorageManager, - }; + use crate::{auth::AuthManager, embeddings::EmbeddingClient, storage::StorageManager}; + use axum::body::{Body, to_bytes}; use std::sync::Arc; use tokio::sync::Mutex; + use tower::util::ServiceExt; async fn build_test_http_state(db_path: &str) -> HttpState { let embedding_client = Arc::new(Mutex::new(EmbeddingClient::stub_for_tests())); @@ -2560,9 +3979,17 @@ mod tests { .await .expect("rag"), ); - let access_manager = Arc::new(NamespaceAccessManager::new( - NamespaceSecurityConfig::default(), - )); + // Track C: AuthManager with a sibling token store path so that any + // create_token call during these HTTP tests can persist without + // erroring on an empty path. The legacy default (security disabled) + // is preserved by not pre-seeding any tokens. + let tokens_path = std::path::Path::new(db_path) + .parent() + .map(|p| p.join("tokens.json")) + .unwrap_or_else(|| std::path::PathBuf::from(format!("{}-tokens.json", db_path))) + .to_string_lossy() + .to_string(); + let auth_manager = Arc::new(AuthManager::new(tokens_path, None)); HttpState { rag: rag.clone(), @@ -2572,13 +3999,19 @@ mod tests { embedding_client, 1024 * 1024, vec![], - access_manager, + auth_manager, )), mcp_sessions: Arc::new(McpSessionManager::new()), mcp_base_url: Arc::new(RwLock::new("http://127.0.0.1:0/mcp/messages/".to_string())), cached_namespaces: Arc::new(RwLock::new(None)), namespace_activity: Arc::new(RwLock::new(HashMap::new())), + last_successful_append_at: Arc::new(RwLock::new(None)), + diagnostic_dry_run_approvals: Arc::new(RwLock::new(HashMap::new())), auth_token: None, + auth_mode: AuthMode::MutatingOnly, + allow_query_token: false, + auth_manager: None, + dashboard_oidc: None, } } @@ -2700,7 +4133,7 @@ mod tests { assert_eq!(overview.total_documents, 42); assert_eq!(overview.db_path, "/tmp/memex"); - assert_eq!(status["cache_ready"], true); + assert!(status["cache_ready"].as_bool().unwrap()); assert_eq!(status["namespace_count"], 2); assert_eq!(status["hint"], "OK"); } @@ -2730,6 +4163,82 @@ mod tests { assert_eq!(namespace_ids, vec!["alpha", "beta"]); } + #[tokio::test] + async fn test_context_pack_route_rebuilds_clustered_source_context() { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join(".lancedb"); + let db_path_str = db_path.to_string_lossy().to_string(); + let state = build_test_http_state(&db_path_str).await; + let storage = state.rag.storage_manager(); + let path = "/tmp/codex__019d749e-5b30-7f33-8bb4-a3a6e21b66c4__clean.md"; + + let mut outer = ChromaDocument::new_flat_with_hashes( + "outer-hit".to_string(), + "kb:context".to_string(), + vec![0.5, 0.25], + json!({"path": path, "source_path": path}), + "Outer summary about a release decision.".to_string(), + "chunk-outer".to_string(), + Some("source-shared".to_string()), + ); + outer.layer = SliceLayer::Outer.as_u8(); + + let mut core = ChromaDocument::new_flat_with_hashes( + "core-source".to_string(), + "kb:context".to_string(), + vec![0.5, 0.25], + json!({"path": path, "source_path": path}), + "# Full Transcript\n\nDecision: ship the context-pack route.".to_string(), + "chunk-core".to_string(), + Some("source-shared".to_string()), + ); + core.layer = SliceLayer::Core.as_u8(); + + storage + .add_to_store(vec![outer, core]) + .await + .expect("seed context docs"); + + let app = create_router(state, &HttpServerConfig::default()); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/context-pack") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + json!({ + "namespace": "kb:context", + "ids": ["outer-hit"], + "view": "full" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(body["selected_ids"], json!(["outer-hit"])); + assert_eq!(body["duplicate_count"], 0); + assert_eq!(body["clusters"].as_array().unwrap().len(), 1); + assert_eq!( + body["sources"][0]["status"], "rebuilt_from_core_chunk", + "{body}" + ); + assert!( + body["markdown"] + .as_str() + .unwrap() + .contains("Decision: ship the context-pack route."), + "{body}" + ); + } + #[test] fn test_chroma_document_maps_to_browse_json() { let doc = ChromaDocument { @@ -2743,6 +4252,7 @@ mod tests { children_ids: vec!["child-1".to_string()], keywords: vec!["hello".to_string()], content_hash: None, + source_hash: None, }; let json_doc: SearchResultJson = doc.into(); @@ -2754,4 +4264,262 @@ mod tests { assert!(json_doc.can_expand); assert!(json_doc.can_drill_up); } + + // ==================================================================== + // Auth validation tests (Track A + Track B) + // ==================================================================== + + #[test] + fn test_constant_time_token_comparison() { + assert!(token_matches("secret123", "secret123")); + assert!(!token_matches("secret123", "secret124")); + assert!(!token_matches("short", "longer_token")); + assert!(!token_matches("", "notempty")); + assert!(token_matches("", "")); + } + + #[test] + fn test_auth_mode_parse() { + assert_eq!(AuthMode::parse("mutating-only"), AuthMode::MutatingOnly); + assert_eq!(AuthMode::parse("all-routes"), AuthMode::AllRoutes); + assert_eq!(AuthMode::parse("namespace-acl"), AuthMode::NamespaceAcl); + assert_eq!(AuthMode::parse("unknown"), AuthMode::MutatingOnly); + assert_eq!(AuthMode::parse(""), AuthMode::MutatingOnly); + } + + #[test] + fn test_cors_wildcard_produces_any() { + // When cors_origins contains "*", the CORS layer should use Any + let config = HttpServerConfig { + cors_origins: vec!["*".to_string()], + bind_address: std::net::Ipv4Addr::new(192, 168, 1, 1).into(), + ..Default::default() + }; + // Verify the config triggers the wildcard branch (no panic = correct path) + let state = build_test_http_state_sync(); + let _router = create_router(state, &config); + } + + fn build_test_http_state_sync() -> HttpState { + // Minimal state for router creation tests (no DB needed) + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join(".lancedb"); + build_test_http_state(db_path.to_str().unwrap()).await + }) + } + + fn build_test_dashboard_oidc_runtime() -> Arc { + Arc::new(DashboardOidcRuntime::new(ResolvedDashboardOidcConfig { + issuer_url: "https://issuer.example".to_string(), + client_id: "rust-memex-dashboard".to_string(), + client_secret: None, + public_base_url: "https://dashboard.example".to_string(), + redirect_url: "https://dashboard.example/auth/callback".to_string(), + scopes: vec!["openid".to_string(), "profile".to_string()], + secure_cookie: true, + })) + } + + #[tokio::test] + async fn test_all_routes_auth_requires_health_and_mcp() { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join(".lancedb"); + let db_path_str = db_path.to_string_lossy().to_string(); + let state = build_test_http_state(&db_path_str).await; + + let app = create_router( + state, + &HttpServerConfig { + auth_token: Some("secret".to_string()), + auth_mode: AuthMode::AllRoutes, + ..HttpServerConfig::default() + }, + ); + + let health = app + .clone() + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(health.status(), StatusCode::UNAUTHORIZED); + + let discovery = app + .clone() + .oneshot( + Request::builder() + .uri("/api/discovery") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(discovery.status(), StatusCode::UNAUTHORIZED); + + let mcp = app + .clone() + .oneshot(Request::builder().uri("/mcp/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(mcp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_all_routes_root_returns_login_page_with_401() { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join(".lancedb"); + let db_path_str = db_path.to_string_lossy().to_string(); + let state = build_test_http_state(&db_path_str).await; + + let app = create_router( + state, + &HttpServerConfig { + auth_token: Some("secret".to_string()), + auth_mode: AuthMode::AllRoutes, + ..HttpServerConfig::default() + }, + ); + + let response = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body_text = String::from_utf8(body.to_vec()).unwrap(); + assert!(body_text.contains("memex_token")); + } + + #[tokio::test] + async fn test_all_routes_accepts_query_token_for_get_routes() { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join(".lancedb"); + let db_path_str = db_path.to_string_lossy().to_string(); + let state = build_test_http_state(&db_path_str).await; + + let app = create_router( + state, + &HttpServerConfig { + auth_token: Some("secret".to_string()), + auth_mode: AuthMode::AllRoutes, + allow_query_token: true, + ..HttpServerConfig::default() + }, + ); + + let response = app + .oneshot( + Request::builder() + .uri("/api/discovery?token=secret") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_oidc_root_redirects_to_login_when_session_missing() { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join(".lancedb"); + let db_path_str = db_path.to_string_lossy().to_string(); + let mut state = build_test_http_state(&db_path_str).await; + state.dashboard_oidc = Some(build_test_dashboard_oidc_runtime()); + + let app = create_router( + state, + &HttpServerConfig { + auth_token: Some("secret".to_string()), + auth_mode: AuthMode::AllRoutes, + ..HttpServerConfig::default() + }, + ); + + let response = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get(header::LOCATION).unwrap(), + "/auth/login" + ); + } + + #[tokio::test] + async fn test_dashboard_session_opens_dashboard_routes_but_not_mcp() { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join(".lancedb"); + let db_path_str = db_path.to_string_lossy().to_string(); + let mut state = build_test_http_state(&db_path_str).await; + let oidc = build_test_dashboard_oidc_runtime(); + oidc.sessions.write().await.insert( + "session-123".to_string(), + DashboardSession { + subject: "user-1".to_string(), + created_at: Instant::now(), + }, + ); + state.dashboard_oidc = Some(oidc); + + let app = create_router( + state, + &HttpServerConfig { + auth_token: Some("secret".to_string()), + auth_mode: AuthMode::AllRoutes, + ..HttpServerConfig::default() + }, + ); + + let discovery = app + .clone() + .oneshot( + Request::builder() + .uri("/api/discovery") + .header(header::COOKIE, "rust_memex_dashboard_session=session-123") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(discovery.status(), StatusCode::OK); + + let search = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/search") + .header(header::COOKIE, "rust_memex_dashboard_session=session-123") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"query":"hello"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(search.status(), StatusCode::UNAUTHORIZED); + assert_ne!(search.status(), StatusCode::FORBIDDEN); + + let mcp = app + .oneshot( + Request::builder() + .uri("/mcp/") + .header(header::COOKIE, "rust_memex_dashboard_session=session-123") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(mcp.status(), StatusCode::UNAUTHORIZED); + } } diff --git a/crates/rust-memex/src/http/recovery.rs b/crates/rust-memex/src/http/recovery.rs new file mode 100644 index 0000000..9c3bc1e --- /dev/null +++ b/crates/rust-memex/src/http/recovery.rs @@ -0,0 +1,412 @@ +use std::convert::Infallible; +use std::path::PathBuf; +use std::time::Duration; + +use axum::{ + Json, Router, + extract::State, + http::StatusCode, + response::sse::{Event, Sse}, + routing::post, +}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::{ + GcConfig, cleanup_versions, collect_garbage, compact_database, + contracts::progress::{CompactProgress, SseEvent}, + merge_databases, + recovery::MaintenanceExecution, + repair_writes, +}; + +use super::HttpState; + +pub(super) fn routes() -> Router { + Router::new() + .route("/api/merge", post(merge_handler)) + .route("/api/repair-writes", post(repair_writes_handler)) + .route("/sse/compact", post(sse_compact_handler)) + .route("/sse/cleanup", post(sse_cleanup_handler)) + .route("/sse/gc", post(sse_gc_handler)) + .route("/sse/optimize", post(sse_optimize_handler)) +} + +#[derive(Debug, Deserialize)] +struct MergeRequest { + sources: Vec, + target: String, + #[serde(default)] + namespace_prefix: Option, + #[serde(default)] + dedup: bool, + #[serde(default)] + dry_run: bool, +} + +#[derive(Debug, Deserialize)] +struct RepairWritesRequest { + #[serde(default)] + namespace: Option, + #[serde(default)] + execute: bool, + #[serde(default)] + json_output: bool, +} + +async fn merge_handler( + Json(request): Json, +) -> Result, (StatusCode, String)> { + if request.sources.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "merge requires at least one source path".to_string(), + )); + } + if request.target.trim().is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "merge requires a non-empty target path".to_string(), + )); + } + + let execution = merge_databases( + request.sources.iter().map(PathBuf::from).collect(), + PathBuf::from(&request.target), + request.dedup, + request.namespace_prefix.clone(), + request.dry_run, + ) + .await + .map_err(internal_error)?; + + Ok(Json(json!({ + "target": execution.target_path.display().to_string(), + "dry_run": request.dry_run, + "dedup": request.dedup, + "namespace_prefix": request.namespace_prefix, + "progress": execution.progress, + }))) +} + +async fn repair_writes_handler( + State(state): State, + Json(request): Json, +) -> Result, (StatusCode, String)> { + let execution = repair_writes( + state.rag.storage_manager().lance_path(), + request.namespace.as_deref(), + request.execute, + ) + .await + .map_err(internal_error)?; + + let payload = if request.json_output { + serde_json::to_value(&execution.report).map_err(internal_error)? + } else { + serde_json::to_value(&execution.result).map_err(internal_error)? + }; + + Ok(Json(payload)) +} + +async fn sse_compact_handler( + State(state): State, +) -> Sse>> { + let stream = async_stream::stream! { + let storage = state.rag.storage_manager(); + let pre_stats = storage.stats().await.ok(); + yield Ok(to_axum_event(sse_event("start", start_payload("compact", storage.lance_path(), pre_stats.as_ref())))); + yield Ok(to_axum_event(running_phase_event("compact", "Merging small files into larger ones"))); + + match compact_database(storage.as_ref()).await { + Ok(outcome) => { + yield Ok(to_axum_event(success_phase_event("compact_done", &outcome.progress, &outcome))); + yield Ok(to_axum_event(done_event("compact", true, &outcome.progress, &outcome))); + } + Err(error) => { + yield Ok(to_axum_event(error_phase_event("compact_error", "compact", &error.to_string()))); + yield Ok(to_axum_event(failed_done_event("compact", &error.to_string()))); + } + } + }; + + sse_response(stream) +} + +async fn sse_cleanup_handler( + State(state): State, +) -> Sse>> { + let stream = async_stream::stream! { + let storage = state.rag.storage_manager(); + let pre_stats = storage.stats().await.ok(); + yield Ok(to_axum_event(sse_event("start", start_payload("cleanup", storage.lance_path(), pre_stats.as_ref())))); + yield Ok(to_axum_event(running_phase_event("cleanup", "Removing old versions (>7 days)"))); + + match cleanup_versions(storage.as_ref(), Some(7)).await { + Ok(outcome) => { + yield Ok(to_axum_event(success_phase_event("cleanup_done", &outcome.progress, &outcome))); + yield Ok(to_axum_event(done_event("cleanup", true, &outcome.progress, &outcome))); + } + Err(error) => { + yield Ok(to_axum_event(error_phase_event("cleanup_error", "cleanup", &error.to_string()))); + yield Ok(to_axum_event(failed_done_event("cleanup", &error.to_string()))); + } + } + }; + + sse_response(stream) +} + +async fn sse_gc_handler( + State(state): State, +) -> Sse>> { + let stream = async_stream::stream! { + let storage = state.rag.storage_manager(); + let pre_stats = storage.stats().await.ok(); + let config = GcConfig { + remove_orphans: true, + dry_run: false, + ..GcConfig::default() + }; + + yield Ok(to_axum_event(sse_event("start", start_payload("gc", storage.lance_path(), pre_stats.as_ref())))); + yield Ok(to_axum_event(running_phase_event("gc", "Removing orphan embeddings"))); + + match collect_garbage(storage.as_ref(), &config).await { + Ok(outcome) => { + yield Ok(to_axum_event(success_phase_event("gc_done", &outcome.progress, &outcome))); + yield Ok(to_axum_event(done_event("gc", true, &outcome.progress, &outcome))); + } + Err(error) => { + yield Ok(to_axum_event(error_phase_event("gc_error", "gc", &error.to_string()))); + yield Ok(to_axum_event(failed_done_event("gc", &error.to_string()))); + } + } + }; + + sse_response(stream) +} + +async fn sse_optimize_handler( + State(state): State, +) -> Sse>> { + let stream = async_stream::stream! { + let storage = state.rag.storage_manager(); + let start_stats = storage.stats().await.ok(); + yield Ok(to_axum_event(sse_event( + "start", + json!({ + "status": "starting_optimization", + "db_path": storage.lance_path(), + "pre_row_count": start_stats.as_ref().map(|s| s.row_count), + "pre_version_count": start_stats.as_ref().map(|s| s.version_count), + }), + ))); + + yield Ok(to_axum_event(running_phase_event("compact", "Merging small files into larger ones"))); + let compact_result = compact_database(storage.as_ref()).await; + match &compact_result { + Ok(outcome) => yield Ok(to_axum_event(success_phase_event("compact_done", &outcome.progress, outcome))), + Err(error) => yield Ok(to_axum_event(error_phase_event("compact_error", "compact", &error.to_string()))), + } + + tokio::time::sleep(Duration::from_millis(10)).await; + + yield Ok(to_axum_event(running_phase_event("prune", "Removing old versions (>7 days)"))); + let cleanup_result = cleanup_versions(storage.as_ref(), Some(7)).await; + match &cleanup_result { + Ok(outcome) => { + let mut progress = outcome.progress.clone(); + progress.phase = "prune".to_string(); + yield Ok(to_axum_event(success_phase_event("prune_done", &progress, outcome))); + } + Err(error) => yield Ok(to_axum_event(error_phase_event("prune_error", "prune", &error.to_string()))), + } + + let end_stats = storage.stats().await.ok(); + yield Ok(to_axum_event(sse_event( + "done", + json!({ + "status": "complete", + "post_row_count": end_stats.as_ref().map(|s| s.row_count), + "post_version_count": end_stats.as_ref().map(|s| s.version_count), + "compact_ok": compact_result.is_ok(), + "prune_ok": cleanup_result.is_ok(), + "elapsed_ms": start_stats + .as_ref() + .and_then(|_| { + compact_result + .as_ref() + .ok() + .and_then(|outcome| outcome.progress.elapsed_ms) + .zip(cleanup_result.as_ref().ok().and_then(|outcome| outcome.progress.elapsed_ms)) + .map(|(compact_ms, cleanup_ms)| compact_ms + cleanup_ms) + }), + }), + ))); + }; + + sse_response(stream) +} + +fn running_phase_event(phase: &str, description: &str) -> SseEvent { + sse_event( + "phase", + json!({ + "phase": phase, + "status": "running", + "description": description, + }), + ) +} + +fn success_phase_event( + event_name: &str, + progress: &CompactProgress, + outcome: &MaintenanceExecution, +) -> SseEvent { + let mut data = compact_progress_payload(progress, outcome); + if let Value::Object(ref mut object) = data { + object.insert("status".to_string(), Value::String("complete".to_string())); + } + sse_event(event_name, data) +} + +fn error_phase_event(event_name: &str, phase: &str, error: &str) -> SseEvent { + sse_event( + event_name, + json!({ + "phase": phase, + "status": "error", + "error": error, + }), + ) +} + +fn done_event( + action: &str, + ok: bool, + progress: &CompactProgress, + outcome: &MaintenanceExecution, +) -> SseEvent { + let mut payload = compact_progress_payload(progress, outcome); + if let Value::Object(ref mut object) = payload { + object.insert("action".to_string(), Value::String(action.to_string())); + object.insert("ok".to_string(), Value::Bool(ok)); + object.insert("status".to_string(), Value::String("complete".to_string())); + } + sse_event("done", payload) +} + +fn failed_done_event(action: &str, error: &str) -> SseEvent { + sse_event( + "done", + json!({ + "action": action, + "ok": false, + "status": "error", + "error": error, + }), + ) +} + +fn compact_progress_payload(progress: &CompactProgress, outcome: &MaintenanceExecution) -> Value { + let mut value = serde_json::to_value(progress).expect("compact progress serializes"); + if let Value::Object(ref mut object) = value { + object.insert( + "pre_row_count".to_string(), + json!(outcome.pre_stats.as_ref().map(|stats| stats.row_count)), + ); + object.insert( + "post_row_count".to_string(), + json!(outcome.post_stats.as_ref().map(|stats| stats.row_count)), + ); + object.insert( + "pre_version_count".to_string(), + json!(outcome.pre_stats.as_ref().map(|stats| stats.version_count)), + ); + object.insert( + "post_version_count".to_string(), + json!(outcome.post_stats.as_ref().map(|stats| stats.version_count)), + ); + object.insert( + "row_delta".to_string(), + json!(stat_delta( + outcome.pre_stats.as_ref().map(|stats| stats.row_count), + outcome.post_stats.as_ref().map(|stats| stats.row_count), + )), + ); + object.insert( + "version_delta".to_string(), + json!(stat_delta( + outcome.pre_stats.as_ref().map(|stats| stats.version_count), + outcome.post_stats.as_ref().map(|stats| stats.version_count), + )), + ); + if let Some(gc_stats) = outcome.gc_stats.as_ref() { + object.insert("orphans_found".to_string(), json!(gc_stats.orphans_found)); + object.insert( + "orphans_removed".to_string(), + json!(gc_stats.orphans_removed), + ); + object.insert( + "empty_namespaces_found".to_string(), + json!(gc_stats.empty_namespaces_found), + ); + object.insert( + "old_docs_removed".to_string(), + json!(gc_stats.old_docs_removed), + ); + } + } + value +} + +fn stat_delta(before: Option, after: Option) -> Option { + before + .zip(after) + .map(|(before, after)| after as i64 - before as i64) +} + +fn start_payload(action: &str, db_path: &str, pre_stats: Option<&crate::TableStats>) -> Value { + json!({ + "action": action, + "db_path": db_path, + "pre_row_count": pre_stats.map(|stats| stats.row_count), + "pre_version_count": pre_stats.map(|stats| stats.version_count), + }) +} + +fn sse_event(event: &str, data: Value) -> SseEvent { + SseEvent { + event: event.to_string(), + id: None, + data, + } +} + +fn to_axum_event(event: SseEvent) -> Event { + let mut axum_event = Event::default() + .event(event.event) + .data(event.data.to_string()); + if let Some(id) = event.id { + axum_event = axum_event.id(id); + } + axum_event +} + +fn sse_response(stream: S) -> Sse> +where + S: futures::Stream> + Send + 'static, +{ + Sse::new(stream).keep_alive( + axum::response::sse::KeepAlive::new() + .interval(Duration::from_secs(15)) + .text("ping"), + ) +} + +fn internal_error(err: impl std::fmt::Display) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) +} diff --git a/src/lib.rs b/crates/rust-memex/src/lib.rs similarity index 68% rename from src/lib.rs rename to crates/rust-memex/src/lib.rs index b785a67..b34729f 100644 --- a/src/lib.rs +++ b/crates/rust-memex/src/lib.rs @@ -1,8 +1,11 @@ +pub mod auth; pub mod common; +pub mod diagnostics; pub mod embeddings; pub mod engine; pub mod handlers; pub mod http; +pub mod lifecycle; pub mod mcp_core; pub mod mcp_protocol; mod mcp_runtime; @@ -10,8 +13,10 @@ pub mod path_utils; pub mod preprocessing; pub mod query; pub mod rag; +pub mod recovery; pub mod search; pub mod security; +pub mod startup; pub mod storage; #[cfg(test)] mod tests; @@ -24,9 +29,13 @@ pub mod progress; pub mod tui; use anyhow::Result; +pub use memex_contracts as contracts; use tracing::Level; // Re-export core types for library consumers +pub use auth::{ + AuthDenial, AuthManager, AuthResult, Scope, TokenEntry, TokenStoreFile, TokenStoreV2, +}; pub use embeddings::{ DEFAULT_REQUIRED_DIMENSION, DimensionAdapter, EmbeddingClient, EmbeddingConfig, MLXBridge, MlxConfig, MlxMergeOptions, ProviderConfig, RerankerConfig, TokenConfig, @@ -49,6 +58,9 @@ pub use query::{ }; pub use rag::{ Chunk as PipelineChunk, + ChunkOpts, + ChunkProvider, + ChunkerKind, ContextPrefixConfig, CrossStoreRecoveryBatchReport, CrossStoreRecoveryReport, @@ -59,6 +71,7 @@ pub use rag::{ IndexResult, OnionSlice, OnionSliceConfig, + OuterSynthesis, PipelineConfig, PipelineEvent, PipelineGovernorConfig, @@ -73,23 +86,37 @@ pub use rag::{ compute_content_hash, create_enriched_chunks, create_onion_slices, + detect_default_chunker, inspect_cross_store_recovery, repair_cross_store_recovery, // Async pipeline exports run_pipeline, }; +pub use recovery::{ + MaintenanceExecution, MergeExecution, RepairExecution, cleanup_versions, collect_garbage, + compact_database, merge_databases, repair_writes, +}; pub use search::{ BM25Config, BM25Index, HybridConfig, HybridSearchResult, HybridSearcher, SearchMode, StemLanguage, }; -pub use security::{NamespaceAccessManager, NamespaceSecurityConfig}; +pub use security::NamespaceSecurityConfig; +pub use startup::{StartupSchemaGuard, guard_daemon_startup_schema}; pub use storage::{ ChromaDocument, CrossStoreRecoveryBatch, CrossStoreRecoveryDocumentRef, - CrossStoreRecoveryStatus, GcConfig, GcStats, StorageManager, TableStats, parse_duration_string, + CrossStoreRecoveryStatus, DEFAULT_TABLE_NAME, GcConfig, GcStats, SchemaMigrationReport, + SchemaMismatchWriteError, SchemaVersion, StorageManager, TableStats, parse_duration_string, + required_columns_for, }; // High-level engine API pub use engine::{BatchResult, MemexConfig, MemexEngine, MetaFilter, StoreItem}; +pub use lifecycle::{ + ExportRecord, ImportOutcome, NamespaceMigrationOutcome, ReindexJob, ReindexOutcome, + ReprocessJob, ReprocessOutcome, default_reindexed_namespace, export_namespace_jsonl_stream, + import_jsonl_bytes_stream, import_jsonl_file, import_jsonl_reader, migrate_namespace_atomic, + reindex_namespace, reprocess_jsonl_file, +}; // Canonical MCP metadata plus local Rust helper API. pub use tools::{ @@ -172,6 +199,7 @@ pub async fn run_stdio_server(config: ServerConfig) -> Result<()> { #[cfg(test)] mod lib_tests { use super::*; + use serde_json::json; #[test] fn default_config_has_expected_values() { @@ -180,4 +208,36 @@ mod lib_tests { assert_eq!(cfg.db_path, "~/.rmcp-servers/rust-memex/lancedb"); assert_eq!(cfg.max_request_bytes, 5 * 1024 * 1024); } + + #[test] + fn contracts_are_reachable_through_main_crate() { + let audit = contracts::audit::AuditResult { + namespace: "animals".to_string(), + document_count: 4, + avg_chunk_length: 256, + sentence_integrity: 0.92, + word_integrity: 0.94, + chunk_quality: 0.9, + overall_score: 0.91, + recommendation: contracts::audit::AuditRecommendation::Good, + passes_threshold: true, + }; + let storage = contracts::stats::StorageMetrics::default(); + let timeline = contracts::timeline::TimelineFilter { + namespace: Some("animals".to_string()), + since: Some("2026-04-19".to_string()), + gaps_only: false, + }; + let progress = contracts::progress::SseEvent { + event: "done".to_string(), + id: Some("evt-1".to_string()), + data: json!({ "status": "ok" }), + }; + + let _: rag::AuditResult = audit.clone(); + let _: http::AuditResult = audit; + let _: http::StorageMetrics = storage; + let _: http::TimelineFilter = timeline; + let _: http::SseEvent = progress; + } } diff --git a/crates/rust-memex/src/lifecycle.rs b/crates/rust-memex/src/lifecycle.rs new file mode 100644 index 0000000..13655d7 --- /dev/null +++ b/crates/rust-memex/src/lifecycle.rs @@ -0,0 +1,963 @@ +use anyhow::{Result, anyhow}; +use async_stream::try_stream; +use axum::body::Bytes; +use futures::{Stream, StreamExt}; +use memex_contracts::progress::{ReindexProgress, ReprocessProgress}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, BufReader}; + +use crate::{ + ChromaDocument, ChunkerKind, PreprocessingConfig, Preprocessor, RAGPipeline, SliceMode, + StorageManager, compute_content_hash, detect_default_chunker, path_utils, +}; + +const EXPORT_PAGE_SIZE: usize = 5_000; + +/// JSONL export record structure shared across CLI and HTTP lifecycle flows. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportRecord { + pub id: String, + pub text: String, + pub metadata: Value, + pub content_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub embeddings: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ReprocessOutcome { + pub target_namespace: String, + pub source_label: String, + pub source_records: usize, + pub canonical_documents: usize, + pub indexed_documents: usize, + pub replaced_documents: usize, + pub skipped_existing_documents: usize, + pub skipped_empty_documents: usize, + pub skipped_preprocess_short_documents: usize, + pub failed_documents: usize, + pub failed_ids: Vec, + pub parse_errors: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ReindexOutcome { + pub source_namespace: String, + pub target_namespace: String, + pub source_records: usize, + pub canonical_documents: usize, + pub indexed_documents: usize, + pub replaced_documents: usize, + pub skipped_documents: usize, + pub failed_documents: usize, + pub failed_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ImportOutcome { + pub imported_count: usize, + pub skipped_count: usize, + pub error_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct NamespaceMigrationOutcome { + pub from_namespace: String, + pub to_namespace: String, + pub migrated_chunks: usize, +} + +#[derive(Debug, Clone)] +pub struct ReprocessJob { + pub input_path: PathBuf, + pub target_namespace: String, + pub slice_mode: SliceMode, + pub chunker: Option, + pub preprocess: bool, + pub skip_existing: bool, + pub allow_duplicates: bool, + pub dry_run: bool, +} + +#[derive(Debug, Clone)] +pub struct ReindexJob { + pub source_namespace: String, + pub target_namespace: String, + pub slice_mode: SliceMode, + pub chunker: Option, + pub preprocess: bool, + pub skip_existing: bool, + pub allow_duplicates: bool, + pub dry_run: bool, +} + +#[derive(Debug, Clone)] +struct ReprocessDocument { + canonical_id: String, + source_record_id: String, + text: String, + metadata: Value, + source_text_hash: String, + collapsed_records: usize, +} + +#[derive(Debug, Clone)] +struct RebuildPlan { + namespace: String, + source_label: String, + source_records: usize, + docs: Vec, + slice_mode: SliceMode, + chunker: Option, + preprocess: bool, + skip_existing: bool, + allow_duplicates: bool, + dry_run: bool, + parse_errors: usize, +} + +#[derive(Debug, Clone, Default)] +struct RebuildProgress { + processed_documents: usize, + indexed_documents: usize, + skipped_documents: usize, + failed_documents: usize, +} + +#[derive(Debug, Clone, Default)] +struct RebuildStats { + indexed_documents: usize, + replaced_documents: usize, + skipped_existing_documents: usize, + skipped_empty_documents: usize, + skipped_preprocess_short_documents: usize, + failed_ids: Vec, +} + +pub fn default_reindexed_namespace(namespace: &str) -> String { + format!("{namespace}-reindexed") +} + +pub fn export_namespace_jsonl_stream( + storage: Arc, + namespace: String, + include_embeddings: bool, +) -> impl Stream> + Send + 'static { + try_stream! { + let mut offset = 0usize; + loop { + let page = storage + .all_documents_page(Some(&namespace), offset, EXPORT_PAGE_SIZE) + .await?; + let page_len = page.len(); + + for doc in page { + let record = ExportRecord { + id: doc.id, + text: doc.document, + metadata: doc.metadata, + content_hash: doc.content_hash, + embeddings: include_embeddings.then_some(doc.embedding), + }; + + let mut line = serde_json::to_string(&record)?; + line.push('\n'); + yield line; + } + + if page_len < EXPORT_PAGE_SIZE { + break; + } + offset += page_len; + } + } +} + +pub async fn import_jsonl_file( + rag: Arc, + namespace: String, + input: &Path, + skip_existing: bool, +) -> Result { + let (_validated, file) = path_utils::safe_open_file_async(input).await?; + let reader = BufReader::new(file); + import_jsonl_reader(rag, namespace, skip_existing, reader).await +} + +pub async fn import_jsonl_reader( + rag: Arc, + namespace: String, + skip_existing: bool, + reader: R, +) -> Result +where + R: AsyncBufRead + Unpin, +{ + let storage = rag.storage_manager(); + let mut outcome = ImportOutcome::default(); + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await? { + if process_import_line( + rag.as_ref(), + storage.as_ref(), + &namespace, + skip_existing, + line.as_bytes(), + &mut outcome, + ) + .await + .is_err() + { + outcome.error_count += 1; + } + } + + Ok(outcome) +} + +pub async fn import_jsonl_bytes_stream( + rag: Arc, + namespace: String, + skip_existing: bool, + mut stream: S, +) -> Result +where + S: Stream> + Unpin, + E: std::fmt::Display, +{ + let storage = rag.storage_manager(); + let mut outcome = ImportOutcome::default(); + let mut buffer = Vec::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|err| anyhow!("multipart stream error: {err}"))?; + buffer.extend_from_slice(&chunk); + + while let Some(position) = buffer.iter().position(|byte| *byte == b'\n') { + let line = buffer.drain(..=position).collect::>(); + if process_import_line( + rag.as_ref(), + storage.as_ref(), + &namespace, + skip_existing, + &line, + &mut outcome, + ) + .await + .is_err() + { + outcome.error_count += 1; + } + } + } + + if !buffer.is_empty() + && process_import_line( + rag.as_ref(), + storage.as_ref(), + &namespace, + skip_existing, + &buffer, + &mut outcome, + ) + .await + .is_err() + { + outcome.error_count += 1; + } + + Ok(outcome) +} + +pub async fn migrate_namespace_atomic( + storage: &StorageManager, + from: &str, + to: &str, +) -> Result { + let migrated_chunks = storage.rename_namespace_atomic(from, to).await?; + Ok(NamespaceMigrationOutcome { + from_namespace: from.to_string(), + to_namespace: to.to_string(), + migrated_chunks, + }) +} + +pub async fn reprocess_jsonl_file( + rag: Arc, + job: ReprocessJob, + mut on_progress: F, +) -> Result +where + F: FnMut(ReprocessProgress), +{ + let ReprocessJob { + input_path, + target_namespace, + slice_mode, + chunker, + preprocess, + skip_existing, + allow_duplicates, + dry_run, + } = job; + let (_validated, content) = path_utils::safe_read_to_string_async(&input_path).await?; + let (records, parse_errors) = parse_export_records(&content); + + if records.is_empty() { + return Ok(ReprocessOutcome { + target_namespace, + source_label: input_path.display().to_string(), + parse_errors, + ..ReprocessOutcome::default() + }); + } + + let source_records = records.len(); + let docs = collapse_export_records(records); + let source_label = input_path.display().to_string(); + let canonical_documents = docs.len(); + + let stats = run_rebuild_documents( + rag, + RebuildPlan { + namespace: target_namespace.clone(), + source_label: source_label.clone(), + source_records, + docs, + slice_mode, + chunker, + preprocess, + skip_existing, + allow_duplicates, + dry_run, + parse_errors, + }, + |progress| { + on_progress(ReprocessProgress { + source_label: source_label.clone(), + processed_documents: progress.processed_documents, + indexed_documents: progress.indexed_documents, + skipped_documents: progress.skipped_documents, + failed_documents: progress.failed_documents, + }); + }, + ) + .await?; + + Ok(ReprocessOutcome { + target_namespace, + source_label, + source_records, + canonical_documents, + indexed_documents: stats.indexed_documents, + replaced_documents: stats.replaced_documents, + skipped_existing_documents: stats.skipped_existing_documents, + skipped_empty_documents: stats.skipped_empty_documents, + skipped_preprocess_short_documents: stats.skipped_preprocess_short_documents, + failed_documents: stats.failed_ids.len(), + failed_ids: stats.failed_ids, + parse_errors, + }) +} + +pub async fn reindex_namespace( + rag: Arc, + job: ReindexJob, + mut on_progress: F, +) -> Result +where + F: FnMut(ReindexProgress), +{ + let ReindexJob { + source_namespace, + target_namespace, + slice_mode, + chunker, + preprocess, + skip_existing, + allow_duplicates, + dry_run, + } = job; + if source_namespace == target_namespace { + return Err(anyhow!( + "Source and target namespace are the same ('{}'). Use a different target namespace.", + source_namespace + )); + } + + let storage = rag.storage_manager(); + let target_count = storage.count_namespace(&target_namespace).await?; + if target_count > 0 && !skip_existing { + return Err(anyhow!( + "Target namespace '{}' already contains {} documents. Pass skip_existing to resume, or use a fresh target namespace.", + target_namespace, + target_count + )); + } + + let mut records = Vec::new(); + let mut offset = 0usize; + loop { + let page = storage + .all_documents_page(Some(&source_namespace), offset, EXPORT_PAGE_SIZE) + .await?; + let page_len = page.len(); + + for doc in page { + records.push(ExportRecord { + id: doc.id, + text: doc.document, + metadata: doc.metadata, + content_hash: doc.content_hash, + embeddings: None, + }); + } + + if page_len < EXPORT_PAGE_SIZE { + break; + } + offset += page_len; + } + + if records.is_empty() { + return Ok(ReindexOutcome { + source_namespace, + target_namespace, + ..ReindexOutcome::default() + }); + } + + let source_records = records.len(); + let docs = collapse_export_records(records); + let canonical_documents = docs.len(); + let progress_namespace = target_namespace.clone(); + + let stats = run_rebuild_documents( + rag, + RebuildPlan { + namespace: target_namespace.clone(), + source_label: format!("namespace:{source_namespace}"), + source_records, + docs, + slice_mode, + chunker, + preprocess, + skip_existing, + allow_duplicates, + dry_run, + parse_errors: 0, + }, + |progress| { + on_progress(ReindexProgress { + namespace: progress_namespace.clone(), + total_files: canonical_documents, + processed_files: progress.processed_documents, + indexed_files: progress.indexed_documents, + skipped_files: progress.skipped_documents, + failed_files: progress.failed_documents, + total_chunks: source_records, + }); + }, + ) + .await?; + + Ok(ReindexOutcome { + source_namespace, + target_namespace, + source_records, + canonical_documents, + indexed_documents: stats.indexed_documents, + replaced_documents: stats.replaced_documents, + skipped_documents: stats.skipped_existing_documents + + stats.skipped_empty_documents + + stats.skipped_preprocess_short_documents, + failed_documents: stats.failed_ids.len(), + failed_ids: stats.failed_ids, + }) +} + +fn preferred_reprocess_id(record: &ExportRecord) -> String { + record + .metadata + .get("original_id") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + record + .metadata + .get("doc_id") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + }) + .map(ToOwned::to_owned) + .unwrap_or_else(|| record.id.clone()) +} + +fn reprocess_layer_rank(metadata: &Value) -> u8 { + match metadata.get("layer").and_then(Value::as_str) { + Some("core") => 4, + Some("inner") => 3, + Some("middle") => 2, + Some("outer") => 1, + _ => 0, + } +} + +fn should_replace_reprocess_candidate( + current: &ReprocessDocument, + candidate: &ReprocessDocument, +) -> bool { + let current_rank = (current.text.len(), reprocess_layer_rank(¤t.metadata)); + let candidate_rank = ( + candidate.text.len(), + reprocess_layer_rank(&candidate.metadata), + ); + candidate_rank > current_rank +} + +fn collapse_export_records(records: Vec) -> Vec { + let mut grouped: HashMap = HashMap::new(); + + for record in records { + let text = record.text.trim(); + if text.is_empty() { + continue; + } + + let candidate = ReprocessDocument { + canonical_id: preferred_reprocess_id(&record), + source_record_id: record.id, + text: text.to_string(), + metadata: record.metadata, + source_text_hash: compute_content_hash(text), + collapsed_records: 1, + }; + + match grouped.entry(candidate.canonical_id.clone()) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(candidate); + } + std::collections::hash_map::Entry::Occupied(mut entry) => { + let total_records = entry.get().collapsed_records + 1; + if should_replace_reprocess_candidate(entry.get(), &candidate) { + let mut replacement = candidate; + replacement.collapsed_records = total_records; + entry.insert(replacement); + } else { + entry.get_mut().collapsed_records = total_records; + } + } + } + } + + let mut docs: Vec = grouped.into_values().collect(); + docs.sort_by(|left, right| left.canonical_id.cmp(&right.canonical_id)); + docs +} + +fn reprocess_slice_mode_name(slice_mode: SliceMode) -> &'static str { + match slice_mode { + SliceMode::Onion => "onion", + SliceMode::OnionFast => "onion-fast", + SliceMode::Flat => "flat", + } +} + +struct ReprocessMetadataInput<'a> { + metadata: &'a Value, + source_record_id: &'a str, + source_text_hash: &'a str, + collapsed_records: usize, + slice_mode: SliceMode, + chunker: Option, + namespace: &'a str, + source_label: &'a str, + preprocess: bool, +} + +fn prepare_reprocess_metadata(input: ReprocessMetadataInput<'_>) -> Value { + let mut map = match input.metadata.clone() { + Value::Object(map) => map, + _ => Map::new(), + }; + + for key in [ + "layer", + "parent_id", + "children_ids", + "original_id", + "slice_mode", + "chunker", + "content_hash", + ] { + map.remove(key); + } + + let selected_chunker = input + .chunker + .unwrap_or_else(|| detect_default_chunker(Path::new(input.source_label), input.namespace)); + map.insert( + "slice_mode".to_string(), + json!(reprocess_slice_mode_name(input.slice_mode)), + ); + map.insert("chunker".to_string(), json!(selected_chunker.name())); + map.insert( + "reprocess_source_record_id".to_string(), + json!(input.source_record_id), + ); + map.insert( + "reprocess_source_hash".to_string(), + json!(input.source_text_hash), + ); + map.insert( + "reprocess_collapsed_records".to_string(), + json!(input.collapsed_records), + ); + map.insert("reprocess_source".to_string(), json!(input.source_label)); + if input.preprocess { + map.insert("reprocess_preprocessed".to_string(), json!(true)); + } + + Value::Object(map) +} + +fn parse_export_records(content: &str) -> (Vec, usize) { + let mut parse_errors = 0usize; + let mut records = Vec::new(); + + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + match serde_json::from_str::(trimmed) { + Ok(record) => records.push(record), + Err(_) => { + parse_errors += 1; + } + } + } + + (records, parse_errors) +} + +async fn process_import_line( + rag: &RAGPipeline, + storage: &StorageManager, + namespace: &str, + skip_existing: bool, + raw_line: &[u8], + outcome: &mut ImportOutcome, +) -> Result<()> { + let text = std::str::from_utf8(raw_line)?.trim(); + if text.is_empty() { + return Ok(()); + } + + let record: ExportRecord = serde_json::from_str(text)?; + let content_hash = record + .content_hash + .clone() + .unwrap_or_else(|| compute_content_hash(&record.text)); + + if skip_existing && storage.has_content_hash(namespace, &content_hash).await? { + outcome.skipped_count += 1; + return Ok(()); + } + + if let Some(embedding) = record.embeddings { + let document = ChromaDocument::new_flat_with_hash( + record.id, + namespace.to_string(), + embedding, + record.metadata, + record.text, + content_hash, + ); + storage.add_to_store(vec![document]).await?; + } else { + rag.memory_upsert(namespace, record.id, record.text, record.metadata) + .await?; + } + + outcome.imported_count += 1; + Ok(()) +} + +async fn run_rebuild_documents( + rag: Arc, + plan: RebuildPlan, + mut emit_progress: F, +) -> Result +where + F: FnMut(&RebuildProgress), +{ + let RebuildPlan { + namespace, + source_label, + source_records, + docs, + slice_mode, + chunker, + preprocess, + skip_existing, + allow_duplicates, + dry_run, + parse_errors: _parse_errors, + } = plan; + + if docs.is_empty() { + return Ok(RebuildStats::default()); + } + + if dry_run { + return Ok(RebuildStats::default()); + } + + rag.embedding_healthcheck().await?; + + let preprocessor = preprocess.then(|| Preprocessor::new(PreprocessingConfig::default())); + let min_length = PreprocessingConfig::default().min_content_length; + let storage = rag.storage_manager(); + let mut stats = RebuildStats::default(); + let mut progress = RebuildProgress::default(); + + for (idx, doc) in docs.iter().enumerate() { + if !allow_duplicates + && storage + .has_source_hash(&namespace, &doc.source_text_hash) + .await? + { + tracing::info!( + "Skip duplicate source during rebuild: {}#{} (source_hash {})", + source_label, + doc.source_record_id, + &doc.source_text_hash[..16] + ); + stats.skipped_existing_documents += 1; + progress.processed_documents = idx + 1; + progress.skipped_documents = stats.skipped_existing_documents + + stats.skipped_empty_documents + + stats.skipped_preprocess_short_documents; + progress.failed_documents = stats.failed_ids.len(); + progress.indexed_documents = stats.indexed_documents; + emit_progress(&progress); + continue; + } + + let existing = rag.lookup_memory(&namespace, &doc.canonical_id).await?; + if let Some(existing_doc) = existing.as_ref() + && skip_existing + && existing_doc + .metadata + .get("reprocess_source_hash") + .and_then(Value::as_str) + == Some(doc.source_text_hash.as_str()) + { + stats.skipped_existing_documents += 1; + progress.processed_documents = idx + 1; + progress.skipped_documents = stats.skipped_existing_documents + + stats.skipped_empty_documents + + stats.skipped_preprocess_short_documents; + progress.failed_documents = stats.failed_ids.len(); + progress.indexed_documents = stats.indexed_documents; + emit_progress(&progress); + continue; + } + + let text = if let Some(preprocessor) = &preprocessor { + preprocessor.extract_semantic_content(&doc.text) + } else { + doc.text.clone() + }; + + if text.trim().is_empty() { + stats.skipped_empty_documents += 1; + progress.processed_documents = idx + 1; + progress.skipped_documents = stats.skipped_existing_documents + + stats.skipped_empty_documents + + stats.skipped_preprocess_short_documents; + progress.failed_documents = stats.failed_ids.len(); + progress.indexed_documents = stats.indexed_documents; + emit_progress(&progress); + continue; + } + + if preprocess && text.trim().len() < min_length { + stats.skipped_preprocess_short_documents += 1; + progress.processed_documents = idx + 1; + progress.skipped_documents = stats.skipped_existing_documents + + stats.skipped_empty_documents + + stats.skipped_preprocess_short_documents; + progress.failed_documents = stats.failed_ids.len(); + progress.indexed_documents = stats.indexed_documents; + emit_progress(&progress); + continue; + } + + let metadata = prepare_reprocess_metadata(ReprocessMetadataInput { + metadata: &doc.metadata, + source_record_id: &doc.source_record_id, + source_text_hash: &doc.source_text_hash, + collapsed_records: doc.collapsed_records, + slice_mode, + chunker, + namespace: &namespace, + source_label: &source_label, + preprocess, + }); + + if existing.is_some() { + stats.replaced_documents += 1; + } + + match rag + .memory_upsert(&namespace, doc.canonical_id.clone(), text, metadata) + .await + { + Ok(()) => { + stats.indexed_documents += 1; + } + Err(err) => { + tracing::warn!( + "Failed to rebuild {}#{} into namespace '{}': {}", + source_label, + doc.canonical_id, + namespace, + err + ); + eprintln!( + "Failed to rebuild {}#{} into namespace '{}': {}", + source_label, doc.canonical_id, namespace, err + ); + stats.failed_ids.push(doc.canonical_id.clone()); + } + } + + progress.processed_documents = idx + 1; + progress.indexed_documents = stats.indexed_documents; + progress.skipped_documents = stats.skipped_existing_documents + + stats.skipped_empty_documents + + stats.skipped_preprocess_short_documents; + progress.failed_documents = stats.failed_ids.len(); + emit_progress(&progress); + } + + let _ = source_records; + Ok(stats) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collapse_export_records_prefers_core_slice_for_same_original_id() { + let records = vec![ + ExportRecord { + id: "outer-1".to_string(), + text: "Short summary".to_string(), + metadata: json!({ + "original_id": "doc-1", + "layer": "outer", + "project": "vista" + }), + content_hash: Some("outer-hash".to_string()), + embeddings: None, + }, + ExportRecord { + id: "core-1".to_string(), + text: "Longer full document content that should win during reprocess collapse." + .to_string(), + metadata: json!({ + "original_id": "doc-1", + "layer": "core", + "project": "vista" + }), + content_hash: Some("core-hash".to_string()), + embeddings: None, + }, + ]; + + let docs = collapse_export_records(records); + assert_eq!(docs.len(), 1); + assert_eq!(docs[0].canonical_id, "doc-1"); + assert_eq!(docs[0].source_record_id, "core-1"); + assert_eq!(docs[0].collapsed_records, 2); + assert!(docs[0].text.contains("full document content")); + } + + #[test] + fn collapse_export_records_falls_back_to_doc_id_then_record_id() { + let records = vec![ + ExportRecord { + id: "record-a".to_string(), + text: "alpha".to_string(), + metadata: json!({"doc_id": "doc-a"}), + content_hash: None, + embeddings: None, + }, + ExportRecord { + id: "record-b".to_string(), + text: "beta".to_string(), + metadata: json!({}), + content_hash: None, + embeddings: None, + }, + ]; + + let docs = collapse_export_records(records); + assert_eq!(docs.len(), 2); + assert_eq!(docs[0].canonical_id, "doc-a"); + assert_eq!(docs[1].canonical_id, "record-b"); + } + + #[test] + fn prepare_reprocess_metadata_replaces_stale_chunk_fields() { + let metadata = json!({ + "original_id": "old-doc", + "layer": "outer", + "slice_mode": "onion", + "content_hash": "stale", + "project": "vista" + }); + + let prepared = prepare_reprocess_metadata(ReprocessMetadataInput { + metadata: &metadata, + source_record_id: "core-1", + source_text_hash: "fresh-hash", + collapsed_records: 4, + slice_mode: SliceMode::OnionFast, + chunker: Some(ChunkerKind::Onion), + namespace: "kb:test", + source_label: "legacy.jsonl", + preprocess: true, + }); + + assert_eq!(prepared["project"], "vista"); + assert_eq!(prepared["slice_mode"], "onion-fast"); + assert_eq!(prepared["chunker"], "onion"); + assert_eq!(prepared["reprocess_source_record_id"], "core-1"); + assert_eq!(prepared["reprocess_source_hash"], "fresh-hash"); + assert_eq!(prepared["reprocess_collapsed_records"], 4); + assert_eq!(prepared["reprocess_source"], "legacy.jsonl"); + assert!(prepared["reprocess_preprocessed"].as_bool().unwrap()); + assert!(prepared.get("layer").is_none()); + assert!(prepared.get("original_id").is_none()); + assert!(prepared.get("content_hash").is_none()); + } + + #[test] + fn default_reindexed_namespace_appends_suffix() { + assert_eq!( + default_reindexed_namespace("kodowanie"), + "kodowanie-reindexed" + ); + } +} diff --git a/src/mcp_core.rs b/crates/rust-memex/src/mcp_core.rs similarity index 100% rename from src/mcp_core.rs rename to crates/rust-memex/src/mcp_core.rs diff --git a/src/mcp_protocol.rs b/crates/rust-memex/src/mcp_protocol.rs similarity index 84% rename from src/mcp_protocol.rs rename to crates/rust-memex/src/mcp_protocol.rs index 3925b47..c1bae49 100644 --- a/src/mcp_protocol.rs +++ b/crates/rust-memex/src/mcp_protocol.rs @@ -1,17 +1,15 @@ -use anyhow::{Result, anyhow}; -use serde_json::{Value, json}; -use std::path::Path; -use std::sync::Arc; -use tokio::sync::Mutex; -use uuid::Uuid; - use crate::{ + auth::{AuthDenial, AuthManager, Scope}, embeddings::EmbeddingClient, query::{QueryRouter, SearchModeRecommendation}, rag::{RAGPipeline, SearchOptions, SliceLayer}, search::{HybridSearcher, SearchMode}, - security::NamespaceAccessManager, }; +use anyhow::{Result, anyhow}; +use serde_json::{Value, json}; +use std::path::Path; +use std::sync::Arc; +use tokio::sync::Mutex; pub const PROTOCOL_VERSION: &str = "2024-11-05"; pub const SERVER_NAME: &str = "rust-memex"; @@ -102,8 +100,6 @@ impl McpMethod { enum McpTool { Health, RagIndex, - RagIndexText, - RagSearch, MemoryUpsert, MemoryGet, MemorySearch, @@ -117,11 +113,9 @@ enum McpTool { } impl McpTool { - const ALL: [Self; 14] = [ + const ALL: [Self; 12] = [ Self::Health, Self::RagIndex, - Self::RagIndexText, - Self::RagSearch, Self::MemoryUpsert, Self::MemoryGet, Self::MemorySearch, @@ -138,8 +132,6 @@ impl McpTool { match name { "health" => Some(Self::Health), "rag_index" => Some(Self::RagIndex), - "rag_index_text" => Some(Self::RagIndexText), - "rag_search" => Some(Self::RagSearch), "memory_upsert" => Some(Self::MemoryUpsert), "memory_get" => Some(Self::MemoryGet), "memory_search" => Some(Self::MemorySearch), @@ -158,8 +150,6 @@ impl McpTool { match self { Self::Health => "health", Self::RagIndex => "rag_index", - Self::RagIndexText => "rag_index_text", - Self::RagSearch => "rag_search", Self::MemoryUpsert => "memory_upsert", Self::MemoryGet => "memory_get", Self::MemorySearch => "memory_search", @@ -196,37 +186,6 @@ impl McpTool { "required": ["path"] } }), - Self::RagIndexText => json!({ - "name": self.name(), - "description": "Index raw text for RAG/memory", - "inputSchema": { - "type": "object", - "properties": { - "text": {"type": "string"}, - "id": {"type": "string"}, - "namespace": {"type": "string"}, - "metadata": {"type": "object"} - }, - "required": ["text"] - } - }), - Self::RagSearch => json!({ - "name": self.name(), - "description": "Search documents using RAG", - "inputSchema": { - "type": "object", - "properties": { - "query": {"type": "string"}, - "k": {"type": "integer", "default": 10}, - "namespace": {"type": "string"}, - "project": {"type": "string", "description": "Filter to documents whose metadata project/project_id matches this value"}, - "deep": {"type": "boolean", "default": false, "description": "Include all onion layers instead of only outer summaries"}, - "mode": {"type": "string", "enum": ["vector", "bm25", "hybrid"], "default": "hybrid", "description": "Search mode: vector (semantic), bm25 (keyword), hybrid (both)"}, - "auto_route": {"type": "boolean", "default": false, "description": "Auto-detect query intent and select optimal search mode. Overrides mode when true."} - }, - "required": ["query"] - } - }), Self::MemoryUpsert => json!({ "name": self.name(), "description": "Upsert a text chunk into vector memory. If the namespace is protected, provide the access token.", @@ -387,7 +346,7 @@ pub struct McpCore { embedding_client: Arc>, max_request_bytes: usize, allowed_paths: Vec, - access_manager: Arc, + auth_manager: Arc, } impl McpCore { @@ -397,7 +356,7 @@ impl McpCore { embedding_client: Arc>, max_request_bytes: usize, allowed_paths: Vec, - access_manager: Arc, + auth_manager: Arc, ) -> Self { Self { rag, @@ -405,7 +364,7 @@ impl McpCore { embedding_client, max_request_bytes, allowed_paths, - access_manager, + auth_manager, } } @@ -413,6 +372,54 @@ impl McpCore { self.rag.clone() } + /// Access the unified auth manager (Track C replacement for the legacy + /// `NamespaceAccessManager`). Always available — if no tokens are + /// configured, every authorize() call for that namespace is permitted. + pub fn auth_manager(&self) -> &AuthManager { + &self.auth_manager + } + + /// MCP-tool per-request namespace access check. + /// + /// Preserves the legacy semantic of `NamespaceAccessManager::verify_access`: + /// * If no tokens are registered that cover `namespace`, access is allowed + /// (namespace is "open"). + /// * If any token covers `namespace`, a matching plaintext token must be + /// supplied via the MCP tool-call `token` argument and it must grant + /// write scope for the namespace. + /// + /// Returns `Ok(())` on success, `Err(message)` on denial. + async fn verify_tool_access(&self, namespace: &str, token: Option<&str>) -> Result<()> { + let tokens = self.auth_manager.list_tokens().await; + let namespace_has_token = tokens + .iter() + .any(|entry| entry.has_namespace_access(namespace)); + + if !namespace_has_token { + // No tokens protect this namespace — legacy "open" behavior. + return Ok(()); + } + + match token { + Some(plaintext) => match self + .auth_manager + .authorize(plaintext, &Scope::Write, Some(namespace)) + .await + { + Ok(_) => Ok(()), + Err(AuthDenial::InvalidToken) | Err(AuthDenial::MissingToken) => Err(anyhow!( + "Access denied: invalid token for namespace '{}'", + namespace + )), + Err(denial) => Err(anyhow!("{}", denial)), + }, + None => Err(anyhow!( + "Access denied: namespace '{}' requires a token. Use namespace_create_token to generate one.", + namespace + )), + } + } + pub fn hybrid_searcher(&self) -> Option> { self.hybrid_searcher.clone() } @@ -523,62 +530,11 @@ impl McpCore { Err(e) => Ok(tool_error(e)), } } - McpTool::RagIndexText => { - let text = args["text"].as_str().unwrap_or("").to_string(); - let namespace = args["namespace"].as_str(); - let metadata = args.get("metadata").cloned().unwrap_or_else(|| json!({})); - let item_id = args - .get("id") - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - .unwrap_or_else(|| Uuid::new_v4().to_string()); - - match self - .rag - .index_text(namespace, item_id.clone(), text, metadata) - .await - { - Ok(returned_id) => { - Ok(text_result(format!("Indexed text with id {}", returned_id))) - } - Err(e) => Ok(tool_error(e)), - } - } - McpTool::RagSearch => { - let query = args["query"].as_str().unwrap_or(""); - let limit = requested_limit(args, 10); - let namespace = args["namespace"].as_str(); - let mode = requested_search_mode(query, args); - let options = requested_search_options(args); - - if let Some(hybrid_result) = self - .try_hybrid_search( - query, - namespace, - limit, - (mode, options.clone()), - id, - SearchShape::Rag, - ) - .await? - { - return Ok(hybrid_result); - } - - match self - .rag - .search_with_options(namespace, query, limit, options) - .await - { - Ok(results) => Ok(text_result_from_json(&results)), - Err(e) => Ok(tool_error(e)), - } - } McpTool::MemoryUpsert => { let namespace = args["namespace"].as_str().unwrap_or("default"); let token = args["token"].as_str(); - self.access_manager - .verify_access(namespace, token) + self.verify_tool_access(namespace, token) .await .map_err(|e| jsonrpc_error(Some(id), -32603, e.to_string()))?; @@ -599,8 +555,7 @@ impl McpCore { let namespace = args["namespace"].as_str().unwrap_or("default"); let token = args["token"].as_str(); - self.access_manager - .verify_access(namespace, token) + self.verify_tool_access(namespace, token) .await .map_err(|e| jsonrpc_error(Some(id), -32603, e.to_string()))?; @@ -615,8 +570,7 @@ impl McpCore { let namespace = args["namespace"].as_str().unwrap_or("default"); let token = args["token"].as_str(); - self.access_manager - .verify_access(namespace, token) + self.verify_tool_access(namespace, token) .await .map_err(|e| jsonrpc_error(Some(id), -32603, e.to_string()))?; @@ -626,14 +580,7 @@ impl McpCore { let options = requested_search_options(args); if let Some(hybrid_result) = self - .try_hybrid_search( - query, - Some(namespace), - limit, - (mode, options.clone()), - id, - SearchShape::Memory, - ) + .try_hybrid_search(query, Some(namespace), limit, (mode, options.clone()), id) .await? { return Ok(hybrid_result); @@ -652,8 +599,7 @@ impl McpCore { let namespace = args["namespace"].as_str().unwrap_or("default"); let token = args["token"].as_str(); - self.access_manager - .verify_access(namespace, token) + self.verify_tool_access(namespace, token) .await .map_err(|e| jsonrpc_error(Some(id), -32603, e.to_string()))?; @@ -667,8 +613,7 @@ impl McpCore { let namespace = args["namespace"].as_str().unwrap_or("default"); let token = args["token"].as_str(); - self.access_manager - .verify_access(namespace, token) + self.verify_tool_access(namespace, token) .await .map_err(|e| jsonrpc_error(Some(id), -32603, e.to_string()))?; @@ -688,9 +633,27 @@ impl McpCore { return Ok(tool_error_message("Namespace is required")); } + // Track C: map legacy per-namespace create to AuthManager. + // id == namespace preserves revoke-by-namespace and + // list-protected semantics without a separate mapping table. + // Scopes grant full access to the namespace. Wildcard + // namespaces are not issued via this MCP path. + // + // Legacy `TokenStore::create_token` overwrote on collision + // (HashMap::insert). Preserve that idempotence by revoking + // an existing entry before creating — effectively a rotate. + let description = description + .unwrap_or_else(|| format!("Auto-created for namespace '{}'", namespace)); + let _ = self.auth_manager.revoke_token(namespace).await; match self - .access_manager - .create_token(namespace, description) + .auth_manager + .create_token( + namespace.to_string(), + vec![Scope::Read, Scope::Write, Scope::Admin], + vec![namespace.to_string()], + None, + description, + ) .await { Ok(token) => Ok(text_result(format!( @@ -707,7 +670,7 @@ impl McpCore { return Ok(tool_error_message("Namespace is required")); } - match self.access_manager.revoke_token(namespace).await { + match self.auth_manager.revoke_token(namespace).await { Ok(true) => Ok(text_result(format!( "Token revoked for namespace '{}'. The namespace is now publicly accessible.", namespace @@ -720,15 +683,40 @@ impl McpCore { } } McpTool::NamespaceListProtected => { - let protected = self.access_manager.list_protected_namespaces().await; + let tokens = self.auth_manager.list_tokens().await; + // Legacy shape: one row per protected namespace. Under the v2 + // auth model a single token can cover many namespaces, so we + // flatten: every non-wildcard namespace listed by any token + // becomes an entry. Dedup by namespace, keeping the most + // recently created description. + let mut protected: std::collections::BTreeMap)> = + std::collections::BTreeMap::new(); + for entry in &tokens { + let created_at = entry.created_at.timestamp(); + let desc = Some(entry.description.clone()); + for ns in &entry.namespaces { + if ns == "*" { + continue; + } + protected + .entry(ns.clone()) + .and_modify(|existing| { + if created_at > existing.0 { + *existing = (created_at, desc.clone()); + } + }) + .or_insert_with(|| (created_at, desc.clone())); + } + } + if protected.is_empty() { Ok(text_result( "No namespaces are currently protected with tokens.", )) } else { let list: Vec = protected - .iter() - .map(|(namespace, created_at, description)| { + .into_iter() + .map(|(namespace, (created_at, description))| { json!({ "namespace": namespace, "created_at": created_at, @@ -740,13 +728,21 @@ impl McpCore { } } McpTool::NamespaceSecurityStatus => { - let enabled = self.access_manager.is_enabled(); - let protected_count = self.access_manager.list_protected_namespaces().await.len(); + // Track C: "enabled" now means "at least one token is + // configured". An empty store is equivalent to the old + // `enabled=false` state: every namespace is accessible. + let has_any = self.auth_manager.has_any_tokens().await; + let tokens = self.auth_manager.list_tokens().await; + let protected_namespaces: std::collections::BTreeSet = tokens + .iter() + .flat_map(|entry| entry.namespaces.iter().cloned()) + .filter(|ns| ns != "*") + .collect(); Ok(text_result(format!( "Namespace security: {}\nProtected namespaces: {}\n\nNote: When security is disabled, all namespaces are accessible without tokens.", - if enabled { "ENABLED" } else { "DISABLED" }, - protected_count + if has_any { "ENABLED" } else { "DISABLED" }, + protected_namespaces.len() ))) } McpTool::Dive => { @@ -838,7 +834,6 @@ impl McpCore { limit: usize, search: (SearchMode, SearchOptions), id: &Value, - shape: SearchShape, ) -> std::result::Result, Value> { let (mode, options) = search; if mode == SearchMode::Vector { @@ -862,49 +857,25 @@ impl McpCore { .await .map_err(|e| jsonrpc_error(Some(id), -32603, format!("Hybrid search failed: {}", e)))?; - let payload: Vec = match shape { - SearchShape::Rag => results - .iter() - .map(|result| { - json!({ - "id": result.id, - "namespace": result.namespace, - "document": result.document, - "combined_score": result.combined_score, - "vector_score": result.vector_score, - "bm25_score": result.bm25_score, - "metadata": result.metadata, - "layer": result.layer.as_ref().map(|layer| format!("{:?}", layer)), - "keywords": result.keywords - }) - }) - .collect(), - SearchShape::Memory => results - .iter() - .map(|result| { - json!({ - "id": result.id, - "namespace": result.namespace, - "text": result.document, - "score": result.combined_score, - "vector_score": result.vector_score, - "bm25_score": result.bm25_score, - "metadata": result.metadata - }) + let payload: Vec = results + .iter() + .map(|result| { + json!({ + "id": result.id, + "namespace": result.namespace, + "text": result.document, + "score": result.combined_score, + "vector_score": result.vector_score, + "bm25_score": result.bm25_score, + "metadata": result.metadata }) - .collect(), - }; + }) + .collect(); Ok(Some(text_result_from_json(&payload))) } } -#[derive(Clone, Copy)] -enum SearchShape { - Rag, - Memory, -} - fn requested_search_mode(query: &str, args: &Value) -> SearchMode { if args["auto_route"].as_bool().unwrap_or(false) { let router = QueryRouter::new(); @@ -1076,7 +1047,7 @@ mod tests { fn jsonrpc_success_omits_null_id() { let response = jsonrpc_success(&Value::Null, json!({"ok": true})); assert_eq!(response["jsonrpc"], "2.0"); - assert_eq!(response["result"]["ok"], true); + assert!(response["result"]["ok"].as_bool().unwrap()); assert_eq!(response.get("id"), None); } diff --git a/src/mcp_runtime.rs b/crates/rust-memex/src/mcp_runtime.rs similarity index 75% rename from src/mcp_runtime.rs rename to crates/rust-memex/src/mcp_runtime.rs index 3323039..4d9b521 100644 --- a/src/mcp_runtime.rs +++ b/crates/rust-memex/src/mcp_runtime.rs @@ -4,10 +4,10 @@ use tokio::sync::Mutex; use crate::{ ServerConfig, + auth::AuthManager, embeddings::EmbeddingClient, mcp_core::McpCore, search::{BM25Index, HybridSearcher}, - security::NamespaceAccessManager, storage::StorageManager, }; @@ -53,9 +53,19 @@ pub async fn build_mcp_core(config: ServerConfig) -> Result> { .await?, ); - let access_manager = NamespaceAccessManager::new(config.security.clone()); - access_manager.init().await?; - let access_manager = Arc::new(access_manager); + // Track C: build AuthManager from NamespaceSecurityConfig. When security + // is disabled we still wire up a manager (it's effectively a no-op with + // an empty store), so the MCP core never needs to branch on presence. + let store_path = config + .security + .token_store_path + .clone() + .unwrap_or_else(|| "~/.rmcp-servers/rust-memex/tokens.json".to_string()); + let auth_manager = AuthManager::new(store_path, None); + if config.security.enabled { + auth_manager.init().await?; + } + let auth_manager = Arc::new(auth_manager); Ok(Arc::new(McpCore::new( rag, @@ -63,6 +73,6 @@ pub async fn build_mcp_core(config: ServerConfig) -> Result> { embedding_client, config.max_request_bytes, config.allowed_paths, - access_manager, + auth_manager, ))) } diff --git a/src/path_utils.rs b/crates/rust-memex/src/path_utils.rs similarity index 95% rename from src/path_utils.rs rename to crates/rust-memex/src/path_utils.rs index 1e775b8..ad88c92 100644 --- a/src/path_utils.rs +++ b/crates/rust-memex/src/path_utils.rs @@ -262,6 +262,18 @@ pub async fn safe_read_to_string_async(path: &Path) -> Result<(PathBuf, String)> Ok((validated, contents)) } +/// Async variant: validate path and open a file in one atomic step. +pub async fn safe_open_file_async(path: &Path) -> Result<(PathBuf, tokio::fs::File)> { + let validated = validate_read_path(path)?; + // Atomic wrapper: `validated` comes from validate_read_path(), which + // canonicalizes the path and enforces the allowed-base policy. + // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path + let file = tokio::fs::File::open(&validated) + .await + .map_err(|e| anyhow!("Failed to open '{}': {}", validated.display(), e))?; + Ok((validated, file)) +} + /// Async variant: validate path and read directory in one atomic step. pub async fn safe_read_dir(path: &Path) -> Result<(PathBuf, tokio::fs::ReadDir)> { let validated = validate_read_path(path)?; diff --git a/src/preprocessing/mod.rs b/crates/rust-memex/src/preprocessing/mod.rs similarity index 100% rename from src/preprocessing/mod.rs rename to crates/rust-memex/src/preprocessing/mod.rs diff --git a/src/preprocessing/tests.rs b/crates/rust-memex/src/preprocessing/tests.rs similarity index 100% rename from src/preprocessing/tests.rs rename to crates/rust-memex/src/preprocessing/tests.rs diff --git a/src/progress.rs b/crates/rust-memex/src/progress.rs similarity index 100% rename from src/progress.rs rename to crates/rust-memex/src/progress.rs diff --git a/src/query/mod.rs b/crates/rust-memex/src/query/mod.rs similarity index 100% rename from src/query/mod.rs rename to crates/rust-memex/src/query/mod.rs diff --git a/src/query/router.rs b/crates/rust-memex/src/query/router.rs similarity index 100% rename from src/query/router.rs rename to crates/rust-memex/src/query/router.rs diff --git a/src/rag/mod.rs b/crates/rust-memex/src/rag/mod.rs similarity index 71% rename from src/rag/mod.rs rename to crates/rust-memex/src/rag/mod.rs index a9fd1e0..271b869 100644 --- a/src/rag/mod.rs +++ b/crates/rust-memex/src/rag/mod.rs @@ -1,4 +1,8 @@ use anyhow::{Result, anyhow}; +pub use memex_contracts::audit::{AuditRecommendation, AuditResult, ChunkQuality, QualityTier}; +pub use memex_contracts::progress::{ + AuditProgress, MergeProgress, ReindexProgress, RepairResult, ReprocessProgress, +}; use pdf_extract; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -19,11 +23,16 @@ use crate::{ // Async pipeline module for concurrent indexing pub mod pipeline; +pub mod provider; pub mod structured; pub use pipeline::{ Chunk, EmbeddedChunk, FileContent, PipelineConfig, PipelineEvent, PipelineGovernorConfig, PipelineResult, PipelineSnapshot, PipelineStats, run_pipeline, }; +pub use provider::{ + AicxChunkProvider, ChunkOpts, ChunkProvider, ChunkerKind, FlatChunkProvider, + OnionChunkProvider, detect_default_chunker, +}; const DEFAULT_NAMESPACE: &str = "rag"; @@ -167,6 +176,10 @@ pub enum IndexResult { chunks_indexed: usize, /// Content hash for the indexed content content_hash: String, + /// Time spent in embedding calls (ms) + embedder_ms: Option, + /// Estimated total tokens for this content + tokens_estimated: Option, }, /// Content was skipped because it already exists (exact-match duplicate) Skipped { @@ -433,6 +446,23 @@ pub fn compute_content_hash(content: &str) -> String { result.iter().map(|b| format!("{:02x}", b)).collect() } +/// Strategy for producing the outer (~100 char) layer. +/// +/// `Keyword` is the fast, dependency-free TF-based path used by all current +/// callers. `Llm` is the spec P3 escape hatch: an external Ollama instance +/// summarizes the document into 1–3 readable sentences. We expose it so the +/// pipeline can be re-routed without forking the slicer once an Ollama +/// endpoint is wired up. +#[derive(Debug, Clone, Default)] +pub enum OuterSynthesis { + /// TF-based keyword extraction (existing behavior). Cheap, no I/O. + #[default] + Keyword, + /// Send the top-N inner chunks to an Ollama model and use the response + /// as the outer summary. `endpoint` defaults to `http://localhost:11434`. + Llm { model: String, endpoint: String }, +} + /// Configuration for onion slicing #[derive(Debug, Clone)] pub struct OnionSliceConfig { @@ -444,6 +474,9 @@ pub struct OnionSliceConfig { pub inner_target: usize, /// Minimum content length to apply onion slicing (below this, use single Core slice) pub min_content_for_slicing: usize, + /// How to build the outer summary. `Keyword` (default) keeps the legacy + /// TF-IDF path; `Llm` routes through Ollama (spec P3, opt-in). + pub outer_synthesis: OuterSynthesis, } impl Default for OnionSliceConfig { @@ -453,21 +486,38 @@ impl Default for OnionSliceConfig { middle_target: 300, inner_target: 600, min_content_for_slicing: 200, + outer_synthesis: OuterSynthesis::default(), } } } fn create_core_only_slice(content: &str) -> Vec { let core_id = OnionSlice::generate_id(content, SliceLayer::Core); - let keywords = extract_keywords(content, 5); - vec![OnionSlice { - id: core_id, - layer: SliceLayer::Core, - content: content.to_string(), - parent_id: None, - children_ids: vec![], - keywords, - }] + let core_keywords = extract_keywords(content, 5); + + // Short content still needs an Outer slice so default search (layer=Outer) + // can find it. Without this, short onion entries are invisible unless deep=true. + let outer_id = OnionSlice::generate_id(content, SliceLayer::Outer); + let outer_keywords = extract_keywords(content, 3); + + vec![ + OnionSlice { + id: outer_id.clone(), + layer: SliceLayer::Outer, + content: content.to_string(), + parent_id: Some(core_id.clone()), + children_ids: vec![], + keywords: outer_keywords, + }, + OnionSlice { + id: core_id, + layer: SliceLayer::Core, + content: content.to_string(), + parent_id: None, + children_ids: vec![outer_id], + keywords: core_keywords, + }, + ] } /// Create onion slices from content @@ -555,6 +605,217 @@ pub fn create_onion_slices( slices } +/// Maximum prompt-input length (chars) sent to Ollama. Spec calls for top-N +/// chunks; the leading window catches intent + early decisions while keeping +/// the context window comfortably under qwen2.5/phi mini limits. +const OLLAMA_OUTER_INPUT_CHAR_BUDGET: usize = 8_000; + +/// Hard timeout for the Ollama HTTP call. Spec budgets ~5s/doc on a 4090; 60s +/// covers cold model loads and weak hardware while still failing fast enough +/// that the keyword fallback can take over without stalling the pipeline. +const OLLAMA_OUTER_TIMEOUT_SECS: u64 = 60; + +/// Connect-phase timeout. Independent of the request total so a misconfigured +/// or moved endpoint fails fast (5s) instead of burning the full 60s budget on +/// every doc when Ollama is offline. +const OLLAMA_OUTER_CONNECT_TIMEOUT_SECS: u64 = 5; + +/// Synthesize an outer summary by asking a local Ollama model (spec P3). +/// +/// POSTs `{endpoint}/api/generate` with a non-streaming prompt that asks the +/// model for a 1-3 sentence Polish summary, then returns the parsed +/// `response` field. Returns `None` on any error (network, model load, +/// malformed response, empty completion) so callers can transparently fall +/// back to the keyword outer. +/// +/// The slicer is synchronous CPU code, so async LLM calls happen at the +/// pipeline boundary: pipeline reads `OuterSynthesis` from its config, +/// invokes this function, and feeds the result into [`replace_outer_slice`] +/// or directly into [`create_onion_slices_async`]. +/// +/// Spec: 2026-04-27 kb-transcripts-onion-slicer-fix-spec, P3. +pub async fn synthesize_outer_via_ollama( + transcript_text: &str, + model: &str, + endpoint: &str, +) -> Option { + let trimmed = transcript_text.trim(); + if trimmed.is_empty() { + return None; + } + + let mut prompt_input: String = trimmed + .chars() + .take(OLLAMA_OUTER_INPUT_CHAR_BUDGET) + .collect(); + if prompt_input.chars().count() < trimmed.chars().count() { + prompt_input.push_str("\n\n[…transcript truncated for outer summary…]"); + } + + let prompt = format!( + "You are a precise transcript summarizer. Output 1-3 sentences in Polish.\n\ + \n\ + Summarize this conversation transcript. Focus on:\n\ + 1. What was the user's goal/question.\n\ + 2. What was decided/built/fixed.\n\ + 3. What was the outcome (success, blocker, follow-up).\n\ + \n\ + Skip UI/CLI noise (Brewing…, Frosting…, Grooving…, tokens·, shifttab, ⎿, ⎯).\n\ + Be specific: name projects, technologies, files mentioned.\n\ + \n\ + Transcript:\n{prompt_input}" + ); + + let url = format!("{}/api/generate", endpoint.trim_end_matches('/')); + let body = serde_json::json!({ + "model": model, + "prompt": prompt, + "stream": false, + }); + + let client = match reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs( + OLLAMA_OUTER_CONNECT_TIMEOUT_SECS, + )) + .timeout(std::time::Duration::from_secs(OLLAMA_OUTER_TIMEOUT_SECS)) + .build() + { + Ok(client) => client, + Err(err) => { + tracing::warn!("Ollama outer synthesis: client build failed: {err}"); + return None; + } + }; + + let response = match client.post(&url).json(&body).send().await { + Ok(response) => response, + Err(err) => { + tracing::warn!("Ollama outer synthesis: POST {url} failed: {err}"); + return None; + } + }; + + let status = response.status(); + if !status.is_success() { + tracing::warn!("Ollama outer synthesis: POST {url} returned status {status}"); + return None; + } + + let parsed: serde_json::Value = match response.json().await { + Ok(value) => value, + Err(err) => { + tracing::warn!("Ollama outer synthesis: response decode failed: {err}"); + return None; + } + }; + + let summary = parsed + .get("response") + .and_then(|value| value.as_str()) + .map(|raw| raw.trim().to_string()) + .filter(|text| !text.is_empty())?; + + Some(summary) +} + +/// Replace the outer slice in a four-layer (or outer+core) onion stack with a +/// new content string, regenerating the outer ID and patching the parent's +/// `children_ids` so the hierarchy stays internally consistent. +/// +/// Used by the async slicers when `OuterSynthesis::Llm` produces a summary +/// that should override the keyword outer. If `slices` contains no `Outer` +/// layer, the input is returned unchanged. +pub fn replace_outer_slice(slices: Vec, new_outer_content: String) -> Vec { + let new_outer_content = new_outer_content.trim().to_string(); + if new_outer_content.is_empty() { + return slices; + } + + let mut old_outer_id: Option = None; + let new_outer_id = OnionSlice::generate_id(&new_outer_content, SliceLayer::Outer); + + let mut rebuilt: Vec = slices + .into_iter() + .map(|slice| { + if slice.layer == SliceLayer::Outer { + old_outer_id = Some(slice.id.clone()); + let new_keywords = extract_keywords(&new_outer_content, 3); + OnionSlice { + id: new_outer_id.clone(), + layer: SliceLayer::Outer, + content: new_outer_content.clone(), + parent_id: slice.parent_id, + children_ids: slice.children_ids, + keywords: new_keywords, + } + } else { + slice + } + }) + .collect(); + + if let Some(old_id) = old_outer_id { + for slice in &mut rebuilt { + for child in &mut slice.children_ids { + if *child == old_id { + *child = new_outer_id.clone(); + } + } + } + } + + rebuilt +} + +/// Async variant of [`create_onion_slices`] that resolves the outer layer via +/// the configured [`OuterSynthesis`] strategy. +/// +/// When `config.outer_synthesis` is [`OuterSynthesis::Llm`] this reaches out to +/// Ollama and (on success) replaces the keyword-derived outer with the model's +/// summary. Any failure (network, malformed response, empty completion) is +/// logged and the function silently falls back to the keyword outer so the +/// pipeline never stalls on transient Ollama unavailability. +pub async fn create_onion_slices_async( + content: &str, + metadata: &serde_json::Value, + config: &OnionSliceConfig, +) -> Vec { + let llm_summary = resolve_llm_outer(content, &config.outer_synthesis).await; + let slices = create_onion_slices(content, metadata, config); + apply_optional_outer_override(slices, llm_summary) +} + +/// Async variant of [`create_onion_slices_fast`] mirroring the LLM-or-keyword +/// resolution from [`create_onion_slices_async`]. +pub async fn create_onion_slices_fast_async( + content: &str, + metadata: &serde_json::Value, + config: &OnionSliceConfig, +) -> Vec { + let llm_summary = resolve_llm_outer(content, &config.outer_synthesis).await; + let slices = create_onion_slices_fast(content, metadata, config); + apply_optional_outer_override(slices, llm_summary) +} + +async fn resolve_llm_outer(content: &str, strategy: &OuterSynthesis) -> Option { + match strategy { + OuterSynthesis::Keyword => None, + OuterSynthesis::Llm { model, endpoint } => { + synthesize_outer_via_ollama(content, model, endpoint).await + } + } +} + +fn apply_optional_outer_override( + slices: Vec, + summary: Option, +) -> Vec { + match summary { + Some(text) => replace_outer_slice(slices, text), + None => slices, + } +} + /// Create fast onion slices (outer + core only) - 2x faster than full onion /// /// For bulk indexing where search quality can be slightly reduced. @@ -609,26 +870,392 @@ pub fn create_onion_slices_fast( slices } -/// Extract keywords from text using simple TF-based extraction +/// English stopwords (top-100 typical filter). +const STOP_WORDS_EN: &[&str] = &[ + "the", + "a", + "an", + "and", + "or", + "but", + "in", + "on", + "at", + "to", + "for", + "of", + "with", + "by", + "from", + "as", + "is", + "was", + "are", + "were", + "been", + "be", + "have", + "has", + "had", + "do", + "does", + "did", + "will", + "would", + "could", + "should", + "may", + "might", + "must", + "shall", + "can", + "this", + "that", + "these", + "those", + "i", + "you", + "he", + "she", + "it", + "we", + "they", + "what", + "which", + "who", + "whom", + "when", + "where", + "why", + "how", + "all", + "each", + "every", + "both", + "few", + "more", + "most", + "other", + "some", + "such", + "no", + "not", + "only", + "own", + "same", + "so", + "than", + "too", + "very", + "just", + "also", + "now", + "here", + "there", + "then", + "once", + "if", + "into", + "through", + "during", + "before", + "after", + "above", + "below", + "between", + "under", + "again", + "further", + "about", + "out", + "over", + "up", + "down", + "off", + "any", + "because", + "until", + "while", + "i'm", + "i've", + "i'll", + "you're", + "he's", + "she's", + "we're", + "they're", + "let's", + "that's", + "isn't", + "wasn't", + "aren't", + "weren't", + "doesn't", + "didn't", + "won't", + "wouldn't", + "shouldn't", + "couldn't", + "haven't", + "hasn't", + "hadn't", +]; + +/// Polish stopwords (top-frequency filter). Driven by spec evidence: +/// kb:transcripts top-5 tokens were `assistant/user/nie/transcript/jest` +/// — the Polish ones (`nie`, `jest`, `już`) need to be filtered. +const STOP_WORDS_PL: &[&str] = &[ + "i", + "w", + "z", + "na", + "do", + "od", + "po", + "za", + "o", + "u", + "to", + "ten", + "ta", + "te", + "ci", + "tej", + "tym", + "się", + "być", + "był", + "była", + "było", + "byli", + "być", + "mam", + "masz", + "ma", + "mamy", + "macie", + "mają", + "jest", + "są", + "jestem", + "jesteś", + "był", + "byli", + "nie", + "tak", + "tu", + "tam", + "już", + "jeszcze", + "też", + "także", + "ale", + "lub", + "albo", + "czy", + "że", + "iż", + "który", + "która", + "które", + "którzy", + "co", + "kto", + "kogo", + "kim", + "czym", + "gdzie", + "kiedy", + "skąd", + "dokąd", + "jak", + "jaki", + "jaka", + "jakie", + "moje", + "moja", + "mój", + "moi", + "twój", + "twoja", + "twoje", + "nasz", + "nasza", + "nasze", + "wasz", + "wasza", + "wasze", + "ich", + "jego", + "jej", + "im", + "mu", + "mi", + "ci", + "go", + "ją", + "je", + "nas", + "was", + "wam", + "nam", + "tylko", + "bardzo", + "bardziej", + "może", + "można", + "trzeba", + "musi", + "powinien", + "raz", + "razy", + "potem", + "wtedy", + "więc", + "wówczas", + "natomiast", + "jednak", + "jeśli", + "jeżeli", + "kiedy", + "podczas", + "przed", + "przez", + "podczas", + "ponieważ", + "dlatego", + "więc", + "zatem", + "tylko", + "także", + "również", + "ponadto", + "oraz", + "lecz", + "kiedyś", + "nigdy", + "zawsze", + "często", + "rzadko", + "czasem", + "może", + "powinno", + "może", +]; + +/// Claude Code / Codex CLI animation gerundy — spec sample plus common variants. +/// These pollute outer keywords for transcript namespaces because they appear +/// 10-20× per file and TF-IDF treats them as discriminative for CLI vs prose. +/// Lower-cased exact matches; tokenizer normalizes input the same way. +const CLI_ANIMATION_GERUNDY: &[&str] = &[ + "brewing", + "cogitating", + "frosting", + "grooving", + "beaming", + "booping", + "schlepping", + "computing", + "mulling", + "pondering", + "meditating", + "reflecting", + "crunching", + "synthesizing", + "distilling", + "forging", + "crafting", + "conjuring", + "whipping", + "channeling", + "decoding", + "encoding", + "reasoning", + "iterating", + "marinating", + "percolating", + "simmering", + "crystallizing", + "massaging", + "tinkering", + "polishing", + "thinking", + "proofing", + "bootstrapping", + "shifttab", + "tokens", + "permissions", + "bypass", + "running", + "thought", +]; + +/// CLI control / decoration tokens that recur in transcript exports. +const CLI_CONTROL_TOKENS: &[&str] = &[ + "shifttab", + "bypass", + "thought", + "thoughts", + "tokens", + "permissions", + "running", + "ran", + "stdout", + "stderr", + "tool", + "input", + "output", + "args", + "result", +]; + +/// Markdown structural words that show up in transcript headers/frontmatter. +/// Per spec these are top-5 across kb:transcripts and contribute zero signal. +const MARKDOWN_STRUCTURAL: &[&str] = &[ + "transcript", + "user", + "assistant", + "system", + "human", + "model", + "date", + "started", + "source", + "cwd", + "session", + "session_id", + "agent", + "slice_mode", + "layer", + "metadata", + "frontmatter", + "claude", + "codex", + "gemini", +]; + +/// Whitespace-tolerant set construction for stop-token lookup. +fn build_default_stop_set() -> std::collections::HashSet<&'static str> { + let mut set = std::collections::HashSet::new(); + set.extend(STOP_WORDS_EN.iter().copied()); + set.extend(STOP_WORDS_PL.iter().copied()); + set.extend(CLI_ANIMATION_GERUNDY.iter().copied()); + set.extend(CLI_CONTROL_TOKENS.iter().copied()); + set.extend(MARKDOWN_STRUCTURAL.iter().copied()); + set +} + +/// Extract keywords from text using simple TF-based extraction. +/// +/// Filters cover: PL+EN top-100 stopwords, Claude Code/Codex CLI gerundy, +/// CLI control tokens, markdown structural words, session-token-shaped +/// strings, and path-like fragments. Driven by the 2026-04-27 onion-slicer +/// fix spec for `kb:transcripts`. fn extract_keywords(text: &str, max_keywords: usize) -> Vec { use std::collections::HashMap; - // Common stop words to filter out - const STOP_WORDS: &[&str] = &[ - "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by", - "from", "as", "is", "was", "are", "were", "been", "be", "have", "has", "had", "do", "does", - "did", "will", "would", "could", "should", "may", "might", "must", "shall", "can", "this", - "that", "these", "those", "i", "you", "he", "she", "it", "we", "they", "what", "which", - "who", "whom", "when", "where", "why", "how", "all", "each", "every", "both", "few", - "more", "most", "other", "some", "such", "no", "not", "only", "own", "same", "so", "than", - "too", "very", "just", "also", "now", "here", "there", "then", "once", "if", "into", - "through", "during", "before", "after", "above", "below", "between", "under", "again", - "further", "about", "out", "over", "up", "down", "off", "any", "because", "until", "while", - ]; - - let stop_set: std::collections::HashSet<&str> = STOP_WORDS.iter().copied().collect(); + let stop_set = build_default_stop_set(); - // Tokenize and count word frequencies let mut word_counts: HashMap = HashMap::new(); for raw in text.split_whitespace() { for token in tokenize_keyword_candidates(raw) { @@ -636,15 +1263,22 @@ fn extract_keywords(text: &str, max_keywords: usize) -> Vec { && token.len() <= 30 && !stop_set.contains(token.as_str()) && !looks_like_session_token(&token) + && !looks_like_path_fragment(&token) { *word_counts.entry(token).or_insert(0) += 1; } } } - // Sort by frequency and take top N let mut words: Vec<_> = word_counts.into_iter().collect(); - words.sort_by(|a, b| b.1.cmp(&a.1)); + // Tie-break alphabetically on the token to make `top-N` deterministic when + // counts collide. `HashMap` iteration order is randomized per-process, so + // a count-only sort would surface a different `top-N` per run whenever the + // relevant tokens share a count (very common for LLM-synthesized outers + // where every meaningful word appears exactly once). The flake this + // protects against was observed in + // `rag::p3_llm_outer_tests::create_onion_slices_async_replaces_outer_with_llm_summary`. + words.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); words .into_iter() @@ -704,6 +1338,82 @@ fn looks_like_session_token(token: &str) -> bool { || (token.len() > 20 && alpha_chars < token.len() / 3) } +/// Detect path-like fragments produced by stripping separators from concatenated +/// directory paths. Driven by spec example: +/// `userssilvergitvistakosmasessionid483fab1b40694c1595aa183cb34a9664...` +/// `portalsrccomponentsdesktopwindowsvistaappwindowtsx145` +/// These tokens are long, alphanumeric, and contain at least one runlength +/// sequence of likely path segments. Filtering them out cleans up outer +/// keyword splat for transcript namespaces. +fn looks_like_path_fragment(token: &str) -> bool { + if token.len() < 30 { + return false; + } + + // Heuristic 1: ≥3 directory-like segment markers in original raw token + // (this only fires if the tokenizer's compacted form preserves them). + let separator_count = token + .chars() + .filter(|ch| matches!(ch, '/' | '_' | '-' | '.')) + .count(); + if separator_count >= 3 { + return true; + } + + // Heuristic 2: looks like compacted path (long alphanum lowercase with + // characteristic shell/directory tokens embedded). + let common_path_segments = [ + "src", + "components", + "users", + "library", + "claude", + "polyversai", + "vibecrafted", + "rust", + "memex", + "session", + "sessionid", + "branch", + "tsx", + "json", + "rs", + "py", + "node_modules", + "git", + ]; + let lower = token.to_ascii_lowercase(); + let segment_hits = common_path_segments + .iter() + .filter(|seg| lower.contains(*seg)) + .count(); + if segment_hits >= 2 { + return true; + } + + // Heuristic 3: alphanum mix with no vowel runs >2 (indicates concatenation + // of unrelated identifiers, not a real word). + let vowels: std::collections::HashSet = + ['a', 'e', 'i', 'o', 'u', 'y'].into_iter().collect(); + let mut max_vowel_run = 0; + let mut current_run = 0; + for ch in token.chars() { + if vowels.contains(&ch.to_ascii_lowercase()) { + current_run += 1; + if current_run > max_vowel_run { + max_vowel_run = current_run; + } + } else { + current_run = 0; + } + } + if token.len() > 40 && max_vowel_run <= 2 { + return true; + } + + false +} + /// Create short hash for document deduplication fn hash_content(text: &str) -> String { let mut hash = compute_content_hash(text); @@ -1684,6 +2394,11 @@ impl RAGPipeline { self.storage.clone() } + pub async fn embedding_healthcheck(&self) -> Result<()> { + self.mlx_bridge.lock().await.embed("healthcheck").await?; + Ok(()) + } + /// Refresh storage to see new data written by other processes pub async fn refresh(&self) -> Result<()> { self.storage.refresh().await @@ -1986,6 +2701,8 @@ impl RAGPipeline { namespace: Option<&str>, slice_mode: SliceMode, ) -> Result { + self.storage.require_current_schema_for_writes().await?; + // Security: validate path before any file operations let validated_path = crate::path_utils::validate_read_path(path)?; let ns = namespace.unwrap_or(DEFAULT_NAMESPACE); @@ -2029,13 +2746,78 @@ impl RAGPipeline { "content_hash": &content_hash, }); - let chunks_indexed = self + let (chunks_indexed, embedder_ms, tokens_estimated) = self .index_with_flat_chunking_and_hash(&text, ns, path, base_metadata, &content_hash) .await?; Ok(IndexResult::Indexed { chunks_indexed, content_hash, + embedder_ms: Some(embedder_ms), + tokens_estimated: Some(tokens_estimated), + }) + } + + /// Index a document with an explicit chunk provider. + pub async fn index_document_with_chunker( + &self, + path: &Path, + namespace: Option<&str>, + chunker: ChunkerKind, + slice_mode: SliceMode, + dedup: bool, + ) -> Result { + if chunker != ChunkerKind::Aicx { + let effective_mode = chunker.slice_mode(slice_mode); + if dedup { + return self + .index_document_with_dedup(path, namespace, effective_mode) + .await; + } + self.index_document_with_mode(path, namespace, effective_mode) + .await?; + return Ok(IndexResult::Indexed { + chunks_indexed: 1, + content_hash: String::new(), + embedder_ms: None, + tokens_estimated: None, + }); + } + + let validated_path = crate::path_utils::validate_read_path(path)?; + let ns = namespace.unwrap_or(DEFAULT_NAMESPACE); + let text = self.extract_text(&validated_path).await?; + let content_hash = compute_content_hash(&text); + + if dedup && self.storage.has_content_hash(ns, &content_hash).await? { + debug!( + "Skipping duplicate content: {} (hash: {})", + path.display(), + &content_hash[..16] + ); + return Ok(IndexResult::Skipped { + reason: "exact duplicate".to_string(), + content_hash, + }); + } + + let file_content = FileContent { + path: validated_path, + text, + namespace: ns.to_string(), + content_hash: content_hash.clone(), + }; + let opts = ChunkOpts::new(chunker, SliceMode::Flat, OuterSynthesis::default()); + let provider = chunker.into_provider(); + let chunks = provider.chunk(&file_content, &opts).await?; + let (chunks_indexed, embedder_ms, tokens_estimated) = + self.embed_and_store_provider_chunks(chunks).await?; + + Ok(IndexResult::Indexed { + chunks_indexed, + content_hash, + embedder_ms: Some(embedder_ms), + tokens_estimated: Some(tokens_estimated), }) } @@ -2054,6 +2836,9 @@ impl RAGPipeline { let documents = self.extract_json_documents(path).await?; let mut total_chunks = 0; + let mut total_embedder_ms = 0u64; + let mut total_tokens_estimated = 0usize; + let mut saw_metrics = false; let mut skipped_docs = 0; let file_content_hash = match crate::path_utils::safe_read_to_string_async(path).await { Ok((_p, content)) => compute_content_hash(&content), @@ -2089,38 +2874,52 @@ impl RAGPipeline { ); } - let chunks = match slice_mode { + let (chunks, embedder_ms, tokens_estimated) = match slice_mode { SliceMode::Onion => { - self.index_with_onion_slicing_and_hash( - &content, - namespace, - doc_metadata, - &doc_hash, - ) - .await? + let (chunks, embedder_ms, tokens_estimated) = self + .index_with_onion_slicing_and_hash( + &content, + namespace, + doc_metadata, + &doc_hash, + ) + .await?; + (chunks, Some(embedder_ms), Some(tokens_estimated)) } SliceMode::OnionFast => { - self.index_with_onion_slicing_fast_and_hash( - &content, - namespace, - doc_metadata, - &doc_hash, - ) - .await? + let (chunks, embedder_ms, tokens_estimated) = self + .index_with_onion_slicing_fast_and_hash( + &content, + namespace, + doc_metadata, + &doc_hash, + ) + .await?; + (chunks, Some(embedder_ms), Some(tokens_estimated)) } SliceMode::Flat => { - self.index_with_flat_chunking_and_hash( - &content, - namespace, - path, - doc_metadata, - &doc_hash, - ) - .await? + let (chunks, embedder_ms, tokens_estimated) = self + .index_with_flat_chunking_and_hash( + &content, + namespace, + path, + doc_metadata, + &doc_hash, + ) + .await?; + (chunks, Some(embedder_ms), Some(tokens_estimated)) } }; total_chunks += chunks; + if let Some(embedder_ms) = embedder_ms { + total_embedder_ms += embedder_ms; + saw_metrics = true; + } + if let Some(tokens_estimated) = tokens_estimated { + total_tokens_estimated += tokens_estimated; + saw_metrics = true; + } } if total_chunks == 0 && skipped_docs > 0 { @@ -2140,6 +2939,8 @@ impl RAGPipeline { Ok(IndexResult::Indexed { chunks_indexed: total_chunks, content_hash: file_content_hash, + embedder_ms: saw_metrics.then_some(total_embedder_ms), + tokens_estimated: saw_metrics.then_some(total_tokens_estimated), }) } @@ -2185,13 +2986,15 @@ impl RAGPipeline { "content_hash": &content_hash, }); - let chunks_indexed = self + let (chunks_indexed, embedder_ms, tokens_estimated) = self .index_with_flat_chunking_and_hash(&cleaned, ns, path, base_metadata, &content_hash) .await?; Ok(IndexResult::Indexed { chunks_indexed, content_hash, + embedder_ms: Some(embedder_ms), + tokens_estimated: Some(tokens_estimated), }) } @@ -2202,6 +3005,8 @@ impl RAGPipeline { preprocess_config: Option, slice_mode: SliceMode, ) -> Result<()> { + self.storage.require_current_schema_for_writes().await?; + // Security: validate path before any file operations let validated_path = crate::path_utils::validate_read_path(path)?; let text = self.extract_text(&validated_path).await?; @@ -2352,10 +3157,12 @@ impl RAGPipeline { namespace: &str, base_metadata: serde_json::Value, content_hash: &str, - ) -> Result { + ) -> Result<(usize, u64, usize)> { let config = OnionSliceConfig::default(); let slices = create_onion_slices(text, &base_metadata, &config); let total_slices = slices.len(); + let mut tokens_estimated = 0; + let token_config = crate::embeddings::TokenConfig::default(); tracing::info!( "Onion slicing: {} chars -> {} slices (outer/middle/inner/core)", @@ -2365,10 +3172,19 @@ impl RAGPipeline { // Process in batches to avoid RAM explosion for large files let mut total_stored = 0; + let mut total_embedder_ms = 0; for batch in slices.chunks(STORAGE_BATCH_SIZE) { + // Estimate tokens for this batch + for slice in batch { + tokens_estimated += + crate::embeddings::estimate_tokens(&slice.content, &token_config); + } + // Embed this batch let batch_contents: Vec = batch.iter().map(|s| s.content.clone()).collect(); + let embed_started_at = std::time::Instant::now(); let embeddings = self.embed_chunks(&batch_contents).await?; + total_embedder_ms += embed_started_at.elapsed().as_millis() as u64; // Create documents from this batch with content hash let mut batch_docs = Vec::with_capacity(batch.len()); @@ -2379,17 +3195,20 @@ impl RAGPipeline { map.insert("keywords".to_string(), json!(slice.keywords)); } - // Dual hash: file_hash for provenance, content_hash for per-slice dedup + // Dual hash: source_hash for provenance, content_hash for per-slice dedup let slice_hash = compute_content_hash(&slice.content); if let serde_json::Value::Object(ref mut map) = metadata { map.insert("file_hash".to_string(), json!(content_hash)); + map.insert("source_hash".to_string(), json!(content_hash)); + map.insert("chunk_hash".to_string(), json!(&slice_hash)); } - let doc = ChromaDocument::from_onion_slice_with_hash( + let doc = ChromaDocument::from_onion_slice_with_hashes( slice, namespace.to_string(), embedding.clone(), metadata, slice_hash, + Some(content_hash.to_string()), ); batch_docs.push(doc); } @@ -2400,7 +3219,7 @@ impl RAGPipeline { tracing::info!("Stored {}/{} slices", total_stored, total_slices); } - Ok(total_slices) + Ok((total_slices, total_embedder_ms, tokens_estimated)) } /// Index using fast onion slice architecture (outer + core only) @@ -2411,10 +3230,12 @@ impl RAGPipeline { namespace: &str, base_metadata: serde_json::Value, content_hash: &str, - ) -> Result { + ) -> Result<(usize, u64, usize)> { let config = OnionSliceConfig::default(); let slices = create_onion_slices_fast(text, &base_metadata, &config); let total_slices = slices.len(); + let mut tokens_estimated = 0; + let token_config = crate::embeddings::TokenConfig::default(); tracing::info!( "Fast onion slicing: {} chars -> {} slices (outer/core only)", @@ -2424,10 +3245,21 @@ impl RAGPipeline { // Process in batches let mut total_stored = 0; + let mut total_embedder_ms = 0; for batch in slices.chunks(STORAGE_BATCH_SIZE) { + // Estimate tokens for this batch + for slice in batch { + tokens_estimated += + crate::embeddings::estimate_tokens(&slice.content, &token_config); + } + + // Embed this batch let batch_contents: Vec = batch.iter().map(|s| s.content.clone()).collect(); + let embed_started_at = std::time::Instant::now(); let embeddings = self.embed_chunks(&batch_contents).await?; + total_embedder_ms += embed_started_at.elapsed().as_millis() as u64; + // Create documents from this batch with content hash let mut batch_docs = Vec::with_capacity(batch.len()); for (slice, embedding) in batch.iter().zip(embeddings.iter()) { let mut metadata = base_metadata.clone(); @@ -2436,17 +3268,20 @@ impl RAGPipeline { map.insert("keywords".to_string(), json!(slice.keywords)); } - // Dual hash: file_hash for provenance, content_hash for per-slice dedup + // Dual hash: source_hash for provenance, content_hash for per-slice dedup let slice_hash = compute_content_hash(&slice.content); if let serde_json::Value::Object(ref mut map) = metadata { map.insert("file_hash".to_string(), json!(content_hash)); + map.insert("source_hash".to_string(), json!(content_hash)); + map.insert("chunk_hash".to_string(), json!(&slice_hash)); } - let doc = ChromaDocument::from_onion_slice_with_hash( + let doc = ChromaDocument::from_onion_slice_with_hashes( slice, namespace.to_string(), embedding.clone(), metadata, slice_hash, + Some(content_hash.to_string()), ); batch_docs.push(doc); } @@ -2456,7 +3291,7 @@ impl RAGPipeline { tracing::info!("Stored {}/{} slices", total_stored, total_slices); } - Ok(total_slices) + Ok((total_slices, total_embedder_ms, tokens_estimated)) } /// Index using traditional flat chunking (backward compatible) @@ -2521,10 +3356,12 @@ impl RAGPipeline { path: &Path, base_metadata: serde_json::Value, content_hash: &str, - ) -> Result { + ) -> Result<(usize, u64, usize)> { // Chunk the text let chunks = self.chunk_text(text, 512, 128)?; let total_chunks = chunks.len(); + let mut tokens_estimated = 0; + let token_config = crate::embeddings::TokenConfig::default(); tracing::info!( "Flat chunking: {} chars -> {} chunks", @@ -2535,9 +3372,16 @@ impl RAGPipeline { // Process in batches to avoid RAM explosion for large files let mut total_stored = 0; let mut global_idx = 0; + let mut total_embedder_ms = 0; for batch in chunks.chunks(STORAGE_BATCH_SIZE) { // Embed this batch + tokens_estimated += batch + .iter() + .map(|chunk| crate::embeddings::estimate_tokens(chunk, &token_config)) + .sum::(); + let embed_started_at = std::time::Instant::now(); let embeddings = self.embed_chunks(batch).await?; + total_embedder_ms += embed_started_at.elapsed().as_millis() as u64; // Create documents from this batch with content hash let mut batch_docs = Vec::with_capacity(batch.len()); @@ -2548,18 +3392,21 @@ impl RAGPipeline { map.insert("total_chunks".to_string(), json!(total_chunks)); } - // Dual hash: file_hash for provenance, content_hash for per-chunk dedup + // Dual hash: source_hash for provenance, content_hash for per-chunk dedup let chunk_hash = compute_content_hash(chunk); if let serde_json::Value::Object(ref mut map) = metadata { map.insert("file_hash".to_string(), json!(content_hash)); + map.insert("source_hash".to_string(), json!(content_hash)); + map.insert("chunk_hash".to_string(), json!(&chunk_hash)); } - let doc = ChromaDocument::new_flat_with_hash( + let doc = ChromaDocument::new_flat_with_hashes( format!("{}_{}", path.to_str().unwrap_or("unknown"), global_idx), namespace.to_string(), embedding.clone(), metadata, chunk.clone(), chunk_hash, + Some(content_hash.to_string()), ); batch_docs.push(doc); global_idx += 1; @@ -2571,7 +3418,68 @@ impl RAGPipeline { tracing::info!("Stored {}/{} chunks", total_stored, total_chunks); } - Ok(total_chunks) + Ok((total_chunks, total_embedder_ms, tokens_estimated)) + } + + async fn embed_and_store_provider_chunks( + &self, + chunks: Vec, + ) -> Result<(usize, u64, usize)> { + let total_chunks = chunks.len(); + let mut tokens_estimated = 0; + let mut total_embedder_ms = 0; + let token_config = crate::embeddings::TokenConfig::default(); + + for batch in chunks.chunks(STORAGE_BATCH_SIZE) { + tokens_estimated += batch + .iter() + .map(|chunk| crate::embeddings::estimate_tokens(&chunk.content, &token_config)) + .sum::(); + + let batch_contents: Vec = + batch.iter().map(|chunk| chunk.content.clone()).collect(); + let embed_started_at = std::time::Instant::now(); + let embeddings = self.embed_chunks(&batch_contents).await?; + total_embedder_ms += embed_started_at.elapsed().as_millis() as u64; + + let documents: Vec = batch + .iter() + .cloned() + .zip(embeddings) + .map(|(chunk, embedding)| { + let source_hash = Some(chunk.source_hash); + if chunk.layer > 0 { + ChromaDocument { + id: chunk.id, + namespace: chunk.namespace, + embedding, + metadata: chunk.metadata, + document: chunk.content, + layer: chunk.layer, + parent_id: chunk.parent_id, + children_ids: chunk.children_ids, + keywords: chunk.keywords, + content_hash: Some(chunk.chunk_hash), + source_hash, + } + } else { + ChromaDocument::new_flat_with_hashes( + chunk.id, + chunk.namespace, + embedding, + chunk.metadata, + chunk.content, + chunk.chunk_hash, + source_hash, + ) + } + }) + .collect(); + + self.persist_documents(documents).await?; + } + + Ok((total_chunks, total_embedder_ms, tokens_estimated)) } async fn index_flat_memory_family_with_hash( @@ -2599,10 +3507,13 @@ impl RAGPipeline { let mut batch_docs = Vec::with_capacity(batch.len()); for (chunk, embedding) in batch.iter().zip(embeddings.iter()) { let mut metadata = base_metadata.clone(); + let chunk_hash = compute_content_hash(chunk); if let serde_json::Value::Object(ref mut map) = metadata { map.insert("chunk_index".to_string(), json!(global_idx)); map.insert("total_chunks".to_string(), json!(total_chunks)); map.insert("file_hash".to_string(), json!(content_hash)); + map.insert("source_hash".to_string(), json!(content_hash)); + map.insert("chunk_hash".to_string(), json!(&chunk_hash)); map.insert("original_id".to_string(), json!(original_id)); } @@ -2612,14 +3523,14 @@ impl RAGPipeline { format!("{original_id}::chunk::{global_idx}") }; - let chunk_hash = compute_content_hash(chunk); - let doc = ChromaDocument::new_flat_with_hash( + let doc = ChromaDocument::new_flat_with_hashes( doc_id, namespace.to_string(), embedding.clone(), metadata, chunk.clone(), chunk_hash, + Some(content_hash.to_string()), ); batch_docs.push(doc); global_idx += 1; @@ -2658,6 +3569,8 @@ impl RAGPipeline { metadata: serde_json::Value, slice_mode: SliceMode, ) -> Result { + self.storage.require_current_schema_for_writes().await?; + let ns = namespace.unwrap_or(DEFAULT_NAMESPACE).to_string(); let slice_mode_name = match slice_mode { SliceMode::Onion => "onion", @@ -2726,6 +3639,7 @@ impl RAGPipeline { text: &str, metadata: serde_json::Value, slice_mode: SliceMode, + chunker: Option, ) -> Result<()> { let slice_mode_name = match slice_mode { SliceMode::Onion => "onion", @@ -2737,11 +3651,41 @@ impl RAGPipeline { if let serde_json::Value::Object(ref mut map) = metadata { map.insert("slice_mode".to_string(), json!(slice_mode_name)); + if let Some(chunker) = chunker { + map.insert("chunker".to_string(), json!(chunker.name())); + } if matches!(slice_mode, SliceMode::Onion | SliceMode::OnionFast) { map.insert("original_id".to_string(), json!(id)); } } + if chunker == Some(ChunkerKind::Aicx) { + let path = metadata + .get("path") + .and_then(serde_json::Value::as_str) + .or_else(|| { + metadata + .get("reprocess_source") + .and_then(serde_json::Value::as_str) + }) + .unwrap_or(id); + let file_content = FileContent { + path: metadata_path_label(path), + text: text.to_string(), + namespace: namespace.to_string(), + content_hash: content_hash.clone(), + }; + let opts = ChunkOpts::new( + ChunkerKind::Aicx, + SliceMode::Flat, + OuterSynthesis::default(), + ); + let provider = ChunkerKind::Aicx.into_provider(); + let chunks = provider.chunk(&file_content, &opts).await?; + self.embed_and_store_provider_chunks(chunks).await?; + return Ok(()); + } + match slice_mode { SliceMode::Onion => { self.index_with_onion_slicing_and_hash(text, namespace, metadata, &content_hash) @@ -2778,6 +3722,8 @@ impl RAGPipeline { text: String, metadata: serde_json::Value, ) -> Result<()> { + self.storage.require_current_schema_for_writes().await?; + let slice_mode = match metadata .get("slice_mode") .and_then(|value| value.as_str()) @@ -2794,10 +3740,18 @@ impl RAGPipeline { )); } }; + let chunker = metadata + .get("chunker") + .and_then(|value| value.as_str()) + .map(str::parse::) + .transpose() + .map_err(anyhow::Error::msg)?; self.delete_memory_family(namespace, &id).await?; - self.index_text_memory_family_with_hash(namespace, &id, &text, metadata, slice_mode) - .await?; + self.index_text_memory_family_with_hash( + namespace, &id, &text, metadata, slice_mode, chunker, + ) + .await?; Ok(()) } @@ -3692,15 +4646,31 @@ fn metadata_matches_project(metadata: &Value, project: &str) -> bool { return true; } + let needle = canonical_project_identity(needle); + metadata.as_object().is_some_and(|object| { ["project", "project_id", "source_project"] .iter() .filter_map(|key| object.get(*key)) .filter_map(|value| value.as_str()) - .any(|value| value.eq_ignore_ascii_case(needle)) + .any(|value| canonical_project_identity(value) == needle) }) } +fn canonical_project_identity(value: &str) -> String { + match value.trim().to_ascii_lowercase().as_str() { + "loctree" | "vetcoders" => "vetcoders".to_string(), + other => other.to_string(), + } +} + +fn metadata_path_label(path: &str) -> std::path::PathBuf { + // This path is provenance metadata passed to the chunker, not a filesystem + // read target. Real file I/O in this module goes through path_utils. + // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path + std::path::PathBuf::from(path) +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct SearchResult { pub id: String, @@ -3815,7 +4785,7 @@ mod tests { )); assert!(metadata_matches_project( &json!({"project_id": "Loctree"}), - "vetcoders" + "loctree" )); assert!(!metadata_matches_project( &json!({"project": "rust-memex"}), @@ -3973,6 +4943,127 @@ Results: assert!(inner.contains("Assistant response:")); } + /// Spec P1 acceptance criterion: top keywords for kb:transcripts must NOT + /// contain any of the boilerplate tokens (`assistant`, `user`, `transcript`, + /// `nie`, `jest`, `Brewing`, `Frosting`, `bypass`, …) regardless of how + /// frequently they appear in the source. This locks the static stoplist — + /// regressions here mean post-rebuild outer chunks immediately go back to + /// the keyword splat the spec was written to fix. + #[test] + fn extract_keywords_drops_spec_boilerplate_even_when_dominant() { + // Synthetic transcript-shaped soup: every banned token appears 3-4×, + // every meaning-bearing token (the "real" signal) appears once. A + // naive TF extractor would surface boilerplate exclusively. + let text = r" + ## user + ## assistant + transcript transcript transcript + user user user user + assistant assistant assistant assistant + Brewing… Brewing… Brewing… Cogitating… + Frosting… Grooving… Grooving… + shifttab shifttab bypass bypass permissions tokens + jest jest jest nie nie nie już też też + VistaPortal LiveTree onionSlicer LanceDB qwen3 + "; + + let keywords = extract_keywords(text, 30); + let lower: Vec = keywords.iter().map(|k| k.to_ascii_lowercase()).collect(); + + let banned = [ + "assistant", + "user", + "transcript", + "system", + "human", + "model", + "session", + "agent", + "claude", + "codex", + "nie", + "jest", + "już", + "też", + "tylko", + "bardzo", + "brewing", + "cogitating", + "frosting", + "grooving", + "beaming", + "thinking", + "shifttab", + "bypass", + "permissions", + "tokens", + "thought", + "running", + ]; + for token in banned { + assert!( + !lower.iter().any(|k| k == token), + "extract_keywords leaked banned boilerplate `{}` into keywords {:?}", + token, + keywords + ); + } + + // And meaningful tokens must survive — at least one real signal word + // shows up. This guards against an over-aggressive filter that would + // make outer chunks empty. + let signal_hits = ["vistaportal", "livetree", "onionslicer", "lancedb", "qwen3"] + .iter() + .filter(|signal| { + lower + .iter() + .any(|k| k.contains(*signal) || k == &(*signal).to_string()) + }) + .count(); + assert!( + signal_hits >= 1, + "stoplist over-filtered: zero meaningful tokens survived in {:?}", + keywords + ); + } + + /// Locks deterministic tie-break ordering inside `extract_keywords`. Every + /// candidate token in this fixture appears exactly once, so without an + /// explicit alphabetical tie-break the `top-N` slice would pick a different + /// subset on every run (HashMap iteration order is randomized per process). + /// The flake this guards against was observed in + /// `rag::p3_llm_outer_tests::create_onion_slices_async_replaces_outer_with_llm_summary`, + /// where the failure mode was `["resolved", "llm", "outer"]` getting picked + /// instead of the meaning-bearing Polish tokens. 50 invocations against the + /// same fixture must produce byte-identical output; if a future refactor + /// drops the tie-break this loop catches it deterministically on CI. + #[test] + fn extract_keywords_is_deterministic_on_count_ties() { + let text = "alpha bravo charlie delta echo foxtrot golf hotel india juliet"; + let baseline = extract_keywords(text, 5); + assert_eq!(baseline.len(), 5); + // Alphabetical tie-break on count=1 must surface the alphabetical + // prefix of the candidate set — never `juliet` first, never `golf` + // first, etc. + assert_eq!( + baseline, + vec![ + "alpha".to_string(), + "bravo".to_string(), + "charlie".to_string(), + "delta".to_string(), + "echo".to_string(), + ] + ); + for _ in 0..50 { + assert_eq!( + extract_keywords(text, 5), + baseline, + "extract_keywords must be deterministic across runs on count ties" + ); + } + } + #[test] fn plain_text_still_uses_generic_fallback_path() { let metadata = json!({ @@ -3998,3 +5089,438 @@ Results: assert!(!outer.contains("Response:")); } } + +#[cfg(test)] +mod p3_llm_outer_tests { + //! Spec P3 acceptance tests: outer-layer synthesis via Ollama. + //! + //! Locks the contract that `OuterSynthesis::Llm` actually performs an HTTP + //! call (not the prior stub that returned `None`), that the prompt + body + //! shape match what an Ollama `/api/generate` instance expects, that the + //! parsed `response` field replaces the keyword outer in both full and + //! fast onion slicers, and that any failure mode (unreachable, non-2xx, + //! malformed JSON, empty completion) silently falls back to the keyword + //! outer so the indexing pipeline never stalls. + use super::*; + use axum::{Json, Router, extract::State, http::StatusCode, routing::post}; + use serde_json::json; + use std::sync::Arc; + use std::sync::Mutex as StdMutex; + use std::time::Duration; + use tokio::net::TcpListener; + use tokio::task::JoinHandle; + + /// Captured request payload from the mock Ollama `/api/generate` endpoint. + type CapturedBody = Arc>>; + + enum MockResponse { + /// Return 200 with a JSON body containing this `response` field. + Ok(&'static str), + /// Return 200 with the literal JSON value (lets us simulate malformed + /// payloads or empty `response`). + OkRaw(serde_json::Value), + /// Return a non-2xx status to simulate model-not-found / overload. + Status(StatusCode), + } + + struct MockOllama { + endpoint: String, + captured: CapturedBody, + _handle: JoinHandle<()>, + } + + async fn spawn_mock_ollama(behavior: MockResponse) -> MockOllama { + let captured: CapturedBody = Arc::new(StdMutex::new(None)); + let captured_for_handler = captured.clone(); + let behavior = Arc::new(behavior); + + async fn handler( + State(state): State<(CapturedBody, Arc)>, + Json(body): Json, + ) -> (StatusCode, Json) { + *state.0.lock().expect("captured mutex poisoned") = Some(body); + match state.1.as_ref() { + MockResponse::Ok(text) => ( + StatusCode::OK, + Json(json!({ "response": text, "done": true })), + ), + MockResponse::OkRaw(value) => (StatusCode::OK, Json(value.clone())), + MockResponse::Status(code) => (*code, Json(json!({"error": "mocked"}))), + } + } + + let app = Router::new() + .route("/api/generate", post(handler)) + .with_state((captured_for_handler, behavior)); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock ollama"); + let addr = listener.local_addr().expect("local_addr"); + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + MockOllama { + endpoint: format!("http://{addr}"), + captured, + _handle: handle, + } + } + + #[tokio::test] + async fn synthesize_outer_via_ollama_posts_correct_payload_and_parses_response() { + let mock = spawn_mock_ollama(MockResponse::Ok( + "Naprawiono onion-slicer P3: outer generowany przez Ollama.", + )) + .await; + + let summary = synthesize_outer_via_ollama( + "User: napraw P3.\nAssistant: Wpięte do pipeline.", + "qwen2.5:3b", + &mock.endpoint, + ) + .await; + + assert_eq!( + summary.as_deref(), + Some("Naprawiono onion-slicer P3: outer generowany przez Ollama.") + ); + + let captured = mock + .captured + .lock() + .expect("captured") + .clone() + .expect("ollama mock did not record the POST body"); + + assert_eq!( + captured.get("model").and_then(|v| v.as_str()), + Some("qwen2.5:3b"), + "model field must be forwarded verbatim" + ); + assert_eq!( + captured.get("stream"), + Some(&json!(false)), + "stream must be false so the helper can read the full response in one shot" + ); + let prompt = captured + .get("prompt") + .and_then(|v| v.as_str()) + .expect("prompt field"); + assert!( + prompt.contains("napraw P3"), + "prompt must include the transcript content" + ); + assert!( + prompt.to_ascii_lowercase().contains("polish"), + "prompt must keep the language directive (Polish summary)" + ); + assert!( + prompt.to_ascii_lowercase().contains("brewing"), + "prompt must instruct the model to skip Claude Code/Codex UI noise" + ); + } + + #[tokio::test] + async fn synthesize_outer_via_ollama_truncates_oversized_input() { + let mock = spawn_mock_ollama(MockResponse::Ok("ok")).await; + let big = "A".repeat(OLLAMA_OUTER_INPUT_CHAR_BUDGET * 2); + + let _ = synthesize_outer_via_ollama(&big, "any", &mock.endpoint).await; + + let prompt = mock + .captured + .lock() + .expect("captured") + .clone() + .expect("body") + .get("prompt") + .and_then(|v| v.as_str()) + .expect("prompt") + .to_string(); + assert!( + prompt.contains("transcript truncated for outer summary"), + "oversized inputs must be truncated with the marker so the model sees the boundary" + ); + // Prompt body length is bounded: budget + truncation marker + fixed + // header + transcript label. A few hundred chars of slack is fine. + assert!( + prompt.chars().count() < OLLAMA_OUTER_INPUT_CHAR_BUDGET + 1_000, + "prompt blew past the input char budget: {} chars", + prompt.chars().count() + ); + } + + #[tokio::test] + async fn synthesize_outer_via_ollama_returns_none_on_non_2xx() { + let mock = spawn_mock_ollama(MockResponse::Status(StatusCode::INTERNAL_SERVER_ERROR)).await; + let summary = synthesize_outer_via_ollama("payload", "model", &mock.endpoint).await; + assert!( + summary.is_none(), + "5xx responses must surface as None (keyword fallback)" + ); + } + + #[tokio::test] + async fn synthesize_outer_via_ollama_returns_none_on_malformed_payload() { + // Missing `response` field. + let mock = spawn_mock_ollama(MockResponse::OkRaw(json!({"done": true}))).await; + let summary = synthesize_outer_via_ollama("payload", "model", &mock.endpoint).await; + assert!(summary.is_none()); + } + + #[tokio::test] + async fn synthesize_outer_via_ollama_returns_none_on_empty_response_field() { + let mock = spawn_mock_ollama(MockResponse::Ok(" \n ")).await; + let summary = synthesize_outer_via_ollama("payload", "model", &mock.endpoint).await; + assert!( + summary.is_none(), + "whitespace-only completions must not pollute the outer layer" + ); + } + + #[tokio::test] + async fn synthesize_outer_via_ollama_returns_none_on_unreachable_endpoint() { + // RFC 5737 TEST-NET-3 (203.0.113.0/24) is reserved for documentation + // and guaranteed not to route in real networks. Combined with the + // helper's 5s connect_timeout this makes the test deterministic and + // robust against parallel-test port-reuse races (a previous version + // bound+dropped a loopback port, which other tests in the suite could + // race-recapture and answer the request, causing flake). + let result = tokio::time::timeout( + Duration::from_secs(15), + synthesize_outer_via_ollama("payload", "model", "http://203.0.113.1:9"), + ) + .await + .expect("synthesize must respect its own connect_timeout in the test budget"); + assert!(result.is_none()); + } + + #[tokio::test] + async fn synthesize_outer_via_ollama_returns_none_on_empty_input() { + // Defense in depth: even if the caller forgets to skip empty docs, we + // must not waste an HTTP roundtrip on whitespace. + let result = synthesize_outer_via_ollama(" \n\t ", "x", "http://127.0.0.1:1").await; + assert!(result.is_none()); + } + + fn long_transcript() -> String { + // Long enough to skip the short-content fast path in + // `create_onion_slices` (`min_content_for_slicing = 200`). + let body = "User asked how to fix the onion slicer outer layer. Assistant proposed wiring Ollama into the pipeline so the outer summary becomes a real Polish sentence instead of a TF-IDF keyword splat. The plan covers prompt construction, response parsing, and graceful fallback when Ollama is unreachable. "; + body.repeat(3) + } + + #[tokio::test] + async fn create_onion_slices_async_replaces_outer_with_llm_summary() { + let mock = spawn_mock_ollama(MockResponse::Ok( + "LLM-resolved outer: streszczenie naprawy slicera onionowego.", + )) + .await; + let config = OnionSliceConfig { + outer_synthesis: OuterSynthesis::Llm { + model: "qwen2.5:3b".to_string(), + endpoint: mock.endpoint.clone(), + }, + ..OnionSliceConfig::default() + }; + let metadata = json!({"type": "note"}); + let content = long_transcript(); + + let slices = create_onion_slices_async(&content, &metadata, &config).await; + let outer = slices + .iter() + .find(|slice| slice.layer == SliceLayer::Outer) + .expect("outer slice present"); + + assert_eq!( + outer.content, + "LLM-resolved outer: streszczenie naprawy slicera onionowego." + ); + + // The middle slice must point at the new outer ID, otherwise the + // hierarchy is silently broken and `expand` walks would fail. + let middle = slices + .iter() + .find(|slice| slice.layer == SliceLayer::Middle) + .expect("middle slice present"); + assert!( + middle.children_ids.contains(&outer.id), + "middle.children_ids must point at the new outer id (got {:?}, outer={})", + middle.children_ids, + outer.id + ); + + // Outer keywords must come from the LLM summary, not the original + // keyword extractor on middle content. + let keyword_lower: Vec = outer + .keywords + .iter() + .map(|k| k.to_ascii_lowercase()) + .collect(); + assert!( + keyword_lower.iter().any(|kw| kw.contains("streszczenie") + || kw.contains("naprawy") + || kw.contains("slicera")), + "outer keywords should reflect the LLM summary, got {:?}", + outer.keywords + ); + } + + #[tokio::test] + async fn create_onion_slices_async_falls_back_to_keyword_when_ollama_unreachable() { + // RFC 5737 TEST-NET-3 — see the unreachable-endpoint helper test for + // why we don't bind+drop a loopback port here. + let config = OnionSliceConfig { + outer_synthesis: OuterSynthesis::Llm { + model: "qwen2.5:3b".to_string(), + endpoint: "http://203.0.113.1:9".to_string(), + }, + ..OnionSliceConfig::default() + }; + let metadata = json!({"type": "note"}); + let content = long_transcript(); + + // The async path must complete and yield non-empty outer content even + // when Ollama is dead — pipeline must not stall. + let slices = tokio::time::timeout( + Duration::from_secs(15), + create_onion_slices_async(&content, &metadata, &config), + ) + .await + .expect("async slicer must not block forever on a dead endpoint"); + let outer = slices + .iter() + .find(|slice| slice.layer == SliceLayer::Outer) + .expect("outer slice present"); + assert!( + !outer.content.trim().is_empty(), + "keyword fallback must produce a usable outer when LLM is unreachable" + ); + + // Sanity: the fallback outer must be a keyword-style outer (the legacy + // bracketed [k1, k2, …] prefix produced by `create_outer_summary`). + // Tie-break ordering inside `extract_keywords` is now deterministic + // (alphabetical on token), so a byte-equality check against the + // keyword baseline would also pass; the bracketed-prefix shape is the + // narrower contract this test is here to lock — the exact keyword + // ordering on a real-world transcript is the keyword extractor's + // problem, not the P3 fallback contract's. + assert!( + outer.content.starts_with('['), + "fallback outer must be the keyword-style bracketed summary, got: {:?}", + outer.content + ); + } + + #[tokio::test] + async fn create_onion_slices_fast_async_replaces_outer_with_llm_summary() { + let mock = spawn_mock_ollama(MockResponse::Ok("Fast onion outer via LLM.")).await; + let config = OnionSliceConfig { + outer_synthesis: OuterSynthesis::Llm { + model: "qwen2.5:3b".to_string(), + endpoint: mock.endpoint.clone(), + }, + ..OnionSliceConfig::default() + }; + let metadata = json!({"type": "note"}); + let content = long_transcript(); + + let slices = create_onion_slices_fast_async(&content, &metadata, &config).await; + // Fast mode emits Outer + Core only. + assert_eq!(slices.len(), 2); + let outer = slices + .iter() + .find(|slice| slice.layer == SliceLayer::Outer) + .expect("fast outer slice present"); + let core = slices + .iter() + .find(|slice| slice.layer == SliceLayer::Core) + .expect("fast core slice present"); + assert_eq!(outer.content, "Fast onion outer via LLM."); + assert!( + core.children_ids.contains(&outer.id), + "fast-mode core must reference the new outer id" + ); + } + + #[tokio::test] + async fn structured_conversation_outer_is_replaced_by_llm_summary() { + // Markdown transcript metadata routes through structured slicer; this + // test guarantees the LLM override applies to the structured path + // (the actual spec target — kb:transcripts). + let mock = spawn_mock_ollama(MockResponse::Ok("Structured outer rewritten by LLM.")).await; + let config = OnionSliceConfig { + outer_synthesis: OuterSynthesis::Llm { + model: "qwen2.5:3b".to_string(), + endpoint: mock.endpoint.clone(), + }, + ..OnionSliceConfig::default() + }; + let metadata = json!({ + "type": "conversation", + "format": "markdown_transcript" + }); + let content = "## user\nNapraw onion slicer P3.\n\n## assistant\nWpiąłem Ollama do pipeline. Dodałem testy. Klucze sa nowe.\n"; + + let slices = create_onion_slices_async(content, &metadata, &config).await; + let outer = slices + .iter() + .find(|slice| slice.layer == SliceLayer::Outer) + .expect("structured outer slice present"); + assert_eq!(outer.content, "Structured outer rewritten by LLM."); + } + + #[test] + fn replace_outer_slice_is_a_noop_when_summary_is_empty() { + let metadata = json!({"type": "note"}); + let content = long_transcript(); + let original = create_onion_slices(&content, &metadata, &OnionSliceConfig::default()); + let cloned = original.clone(); + let after = replace_outer_slice(cloned, " ".to_string()); + assert_eq!(after.len(), original.len()); + for (left, right) in after.iter().zip(original.iter()) { + assert_eq!(left.id, right.id); + assert_eq!(left.content, right.content); + assert_eq!(left.children_ids, right.children_ids); + } + } + + #[test] + fn replace_outer_slice_rewrites_outer_id_and_parent_links() { + let metadata = json!({"type": "note"}); + let content = long_transcript(); + let slices = create_onion_slices(&content, &metadata, &OnionSliceConfig::default()); + let original_outer_id = slices + .iter() + .find(|slice| slice.layer == SliceLayer::Outer) + .expect("outer present") + .id + .clone(); + + let after = replace_outer_slice(slices, "Brand new outer text.".to_string()); + let outer = after + .iter() + .find(|slice| slice.layer == SliceLayer::Outer) + .expect("outer still present"); + assert_eq!(outer.content, "Brand new outer text."); + assert_ne!(outer.id, original_outer_id, "outer id must be regenerated"); + + // No remaining child reference points at the stale id. + for slice in &after { + assert!( + !slice.children_ids.contains(&original_outer_id), + "children_ids must not reference the old outer id (slice layer={:?})", + slice.layer + ); + } + // Some slice now points at the new outer id (the parent). + assert!( + after + .iter() + .any(|slice| slice.children_ids.contains(&outer.id)), + "no slice references the new outer id — hierarchy broken" + ); + } +} diff --git a/src/rag/pipeline.rs b/crates/rust-memex/src/rag/pipeline.rs similarity index 78% rename from src/rag/pipeline.rs rename to crates/rust-memex/src/rag/pipeline.rs index 0218e2b..97e715f 100644 --- a/src/rag/pipeline.rs +++ b/crates/rust-memex/src/rag/pipeline.rs @@ -27,9 +27,8 @@ use tokio::sync::{Mutex, mpsc}; use tracing::{debug, error, info, warn}; use crate::embeddings::EmbeddingClient; -use crate::rag::{ - OnionSlice, OnionSliceConfig, SliceMode, create_onion_slices, create_onion_slices_fast, -}; +use crate::preprocessing::{PreprocessingConfig, Preprocessor}; +use crate::rag::{ChunkOpts, ChunkerKind, OuterSynthesis, SliceMode, detect_default_chunker}; use crate::storage::{ChromaDocument, StorageManager}; /// Channel buffer size for backpressure. @@ -63,7 +62,12 @@ pub struct Chunk { pub source_path: PathBuf, /// Target namespace. pub namespace: String, - /// Content hash of source file (for dedup tracking). + /// SHA256 of THIS chunk's text. Drives chunk-level deduplication. + /// Differs across the four onion layers from one source. + pub chunk_hash: String, + /// SHA256 of the source document (same value across all four onion + /// layers from one source). Drives pre-index source-level dedup so we + /// never re-embed an already-ingested file. pub source_hash: String, /// Onion slice layer (if using onion mode). pub layer: u8, @@ -416,14 +420,33 @@ pub struct PipelineConfig { pub embedder_buffer: usize, /// Slicing mode for chunking. pub slice_mode: SliceMode, + /// Optional provider override. When absent, the chunker stage routes per file. + pub chunker: Option, + /// Outer-layer synthesis strategy for onion modes (spec P3). Defaults to + /// the legacy `Keyword` (TF-based) path; set to `Llm { model, endpoint }` + /// to route the outer layer through a local Ollama model. Failures are + /// logged and silently fall back to keyword outer so the pipeline never + /// stalls on Ollama unavailability. + pub outer_synthesis: OuterSynthesis, /// Enable storage-backed deduplication. pub dedup_enabled: bool, + /// Optional semantic cleanup applied after raw source hashing/dedup and + /// before chunking. The source hash intentionally remains the raw file hash + /// so pipeline outputs stay replayable from the original corpus. + pub preprocess_config: Option, /// Maximum number of embedding requests allowed in flight. pub embed_concurrency: usize, /// Optional adaptive governor for runtime batch/concurrency tuning. pub governor: Option, /// Optional event stream for progress/reporting consumers. pub event_sender: Option>, + /// Total files discovered for this operator-visible run. + /// + /// This can be larger than the scheduled pipeline file count when resume + /// filtered out already committed files before entering the pipeline. + pub discovered_files: usize, + /// Files already satisfied before this pipeline run started. + pub resumed_files: usize, } impl Default for PipelineConfig { @@ -433,10 +456,15 @@ impl Default for PipelineConfig { chunker_buffer: CHANNEL_BUFFER_SIZE, embedder_buffer: CHANNEL_BUFFER_SIZE, slice_mode: SliceMode::default(), + chunker: None, + outer_synthesis: OuterSynthesis::default(), dedup_enabled: true, + preprocess_config: None, embed_concurrency: 1, governor: None, event_sender: None, + discovered_files: 0, + resumed_files: 0, } } } @@ -446,6 +474,10 @@ impl Default for PipelineConfig { pub struct PipelineStats { /// Total files scheduled for this pipeline run. pub total_files: usize, + /// Total files discovered for this operator-visible run. + pub discovered_files: usize, + /// Files already satisfied before this pipeline run started. + pub resumed_files: usize, /// Files successfully read and handed to the chunker. pub files_read: usize, /// Files skipped before chunking (for example exact duplicates). @@ -468,6 +500,8 @@ pub struct PipelineStats { #[derive(Debug, Clone, Default)] pub struct PipelineSnapshot { pub total_files: usize, + pub discovered_files: usize, + pub resumed_files: usize, pub files_read: usize, pub files_skipped: usize, pub files_committed: usize, @@ -499,6 +533,8 @@ impl PipelineSnapshot { pub fn to_stats(&self) -> PipelineStats { PipelineStats { total_files: self.total_files, + discovered_files: self.discovered_files, + resumed_files: self.resumed_files, files_read: self.files_read, files_skipped: self.files_skipped, files_committed: self.files_committed, @@ -562,7 +598,7 @@ pub enum PipelineEvent { stage: &'static str, message: String, }, - Snapshot(PipelineSnapshot), + Snapshot(Box), } /// Result of pipeline execution. @@ -585,12 +621,16 @@ struct PipelineProgressState { impl PipelineProgressState { fn new( total_files: usize, + discovered_files: usize, + resumed_files: usize, initial_runtime: EmbedRuntimeSettings, governor_mode: String, governor_reason: String, ) -> Self { let snapshot = PipelineSnapshot { total_files, + discovered_files, + resumed_files, embed_batch_items_limit: initial_runtime.max_batch_items, embed_batch_chars_limit: initial_runtime.max_batch_chars, embed_concurrency_limit: initial_runtime.concurrency, @@ -710,7 +750,7 @@ impl PipelineProgressState { }); } PipelineEvent::Snapshot(snapshot) => { - self.snapshot = snapshot.clone(); + self.snapshot = snapshot.as_ref().clone(); } } @@ -794,6 +834,8 @@ struct PipelineObserver { impl PipelineObserver { fn new( total_files: usize, + discovered_files: usize, + resumed_files: usize, sender: Option>, initial_runtime: EmbedRuntimeSettings, governor_mode: String, @@ -803,6 +845,8 @@ impl PipelineObserver { sender, state: Arc::new(Mutex::new(PipelineProgressState::new( total_files, + discovered_files, + resumed_files, initial_runtime, governor_mode, governor_reason, @@ -818,7 +862,7 @@ impl PipelineObserver { if let Some(sender) = &self.sender { let _ = sender.send(event); - let _ = sender.send(PipelineEvent::Snapshot(snapshot)); + let _ = sender.send(PipelineEvent::Snapshot(Box::new(snapshot))); } } @@ -829,7 +873,7 @@ impl PipelineObserver { }; if let Some(sender) = &self.sender { - let _ = sender.send(PipelineEvent::Snapshot(snapshot)); + let _ = sender.send(PipelineEvent::Snapshot(Box::new(snapshot))); } } @@ -860,9 +904,15 @@ async fn stage_read_files( namespace: String, storage: Arc, dedup_enabled: bool, + preprocess_config: Option, tx: mpsc::Sender, observer: PipelineObserver, ) { + let preprocessor = preprocess_config.map(|config| { + let min_content_length = config.min_content_length; + (min_content_length, Preprocessor::new(config)) + }); + for path in files { let text = match extract_file_text(&path).await { Ok(text) => text, @@ -882,29 +932,66 @@ async fn stage_read_files( let content_hash = crate::rag::compute_content_hash(&text); if dedup_enabled { - match storage.has_content_hash(&namespace, &content_hash).await { - Ok(true) => { - debug!( - "Skipping duplicate: {:?} (hash: {})", - path, - &content_hash[..16] - ); - observer - .emit(PipelineEvent::FileSkipped { - path: path.clone(), - content_hash, - reason: "exact duplicate".to_string(), - }) - .await; - continue; - } - Ok(false) => {} + // Source-level dedup: skip if any chunk in this namespace already + // came from a document with this exact text (P4). Falls back to + // per-chunk content_hash check for pre-v4 namespaces where the + // source_hash column may not yet exist. + let already_indexed = match storage.has_source_hash(&namespace, &content_hash).await { + Ok(true) => true, + Ok(false) => match storage.has_content_hash(&namespace, &content_hash).await { + Ok(true) => true, + Ok(false) => false, + Err(err) => { + warn!("content_hash dedup fallback failed for {:?}: {}", path, err); + false + } + }, Err(err) => { - warn!("Dedup check failed for {:?}: {}", path, err); + warn!("source_hash dedup check failed for {:?}: {}", path, err); + false } + }; + + if already_indexed { + // Spec P4 acceptance criterion: "Pipeline log: każdy skipped + // duplicate source jedna linia z source path + source_hash." + // Promoted from `debug!` to `info!` so the line shows in the + // default operator run log without needing RUST_LOG=debug; the + // `--allow-duplicates` flag on `index` is the documented escape + // hatch when an operator actually wants to re-embed. + info!( + "Skip duplicate source: {} (source_hash {})", + path.display(), + &content_hash[..16] + ); + observer + .emit(PipelineEvent::FileSkipped { + path: path.clone(), + content_hash, + reason: "exact duplicate".to_string(), + }) + .await; + continue; } } + let text = if let Some((min_content_length, preprocessor)) = &preprocessor { + let cleaned = preprocessor.extract_semantic_content(&text); + if cleaned.trim().len() < *min_content_length { + observer + .emit(PipelineEvent::FileSkipped { + path: path.clone(), + content_hash, + reason: "preprocessed content below min length".to_string(), + }) + .await; + continue; + } + cleaned + } else { + text + }; + let bytes = text.len(); let content = FileContent { path: path.clone(), @@ -958,14 +1045,37 @@ async fn stage_chunk_content( mut rx: mpsc::Receiver, tx: mpsc::Sender, slice_mode: SliceMode, + chunker: Option, + outer_synthesis: OuterSynthesis, observer: PipelineObserver, ) { - let config = OnionSliceConfig::default(); - while let Some(file_content) = rx.recv().await { let path = file_content.path.clone(); let content_hash = file_content.content_hash.clone(); - let chunks = create_chunks_from_content(&file_content, slice_mode, &config); + let selected_chunker = select_pipeline_chunker( + chunker, + slice_mode, + &file_content.path, + &file_content.namespace, + ); + let opts = ChunkOpts::new( + selected_chunker, + selected_chunker.slice_mode(slice_mode), + outer_synthesis.clone(), + ); + let provider = selected_chunker.into_provider(); + let chunks = match provider.chunk(&file_content, &opts).await { + Ok(chunks) => chunks, + Err(err) => { + warn!( + "Chunker {} failed for {}: {}", + provider.name(), + file_content.path.display(), + err + ); + Vec::new() + } + }; let count = chunks.len(); if tx @@ -993,132 +1103,21 @@ async fn stage_chunk_content( info!("Chunker stage complete"); } -/// Create chunks from file content based on slicing mode. -fn create_chunks_from_content( - content: &FileContent, - slice_mode: SliceMode, - config: &OnionSliceConfig, -) -> Vec { - let metadata = serde_json::json!({ - "path": content.path.to_str(), - "content_hash": &content.content_hash, - "slice_mode": match slice_mode { - SliceMode::Onion => "onion", - SliceMode::OnionFast => "onion-fast", - SliceMode::Flat => "flat", - }, - }); - - match slice_mode { - SliceMode::Onion => { - let slices = create_onion_slices(&content.text, &metadata, config); - slices_to_chunks(slices, content) - } - SliceMode::OnionFast => { - let slices = create_onion_slices_fast(&content.text, &metadata, config); - slices_to_chunks(slices, content) - } - SliceMode::Flat => create_flat_chunks(&content.text, content, metadata), +fn select_pipeline_chunker( + explicit: Option, + requested_slice_mode: SliceMode, + source_path: &Path, + namespace: &str, +) -> ChunkerKind { + if let Some(chunker) = explicit { + return chunker; } -} - -/// Convert onion slices to pipeline chunks. -fn slices_to_chunks(slices: Vec, content: &FileContent) -> Vec { - slices - .into_iter() - .map(|slice| { - let metadata = serde_json::json!({ - "path": content.path.to_str(), - "content_hash": &content.content_hash, - "layer": slice.layer.name(), - }); - - Chunk { - id: slice.id, - content: slice.content, - source_path: content.path.clone(), - namespace: content.namespace.clone(), - source_hash: content.content_hash.clone(), - layer: slice.layer.as_u8(), - parent_id: slice.parent_id, - children_ids: slice.children_ids, - keywords: slice.keywords, - metadata, - } - }) - .collect() -} - -/// Create flat chunks from content. -fn create_flat_chunks( - text: &str, - content: &FileContent, - base_metadata: serde_json::Value, -) -> Vec { - let chunks = split_into_chunks(text, 512, 128); - let total_chunks = chunks.len(); - - chunks - .into_iter() - .enumerate() - .map(|(idx, chunk_text)| { - let mut metadata = base_metadata.clone(); - if let serde_json::Value::Object(ref mut map) = metadata { - map.insert("chunk_index".to_string(), serde_json::json!(idx)); - map.insert("total_chunks".to_string(), serde_json::json!(total_chunks)); - } - - let id = format!( - "{}_{}_{}", - content.path.to_str().unwrap_or("unknown"), - content.content_hash.get(..8).unwrap_or(""), - idx - ); - - Chunk { - id, - content: chunk_text, - source_path: content.path.clone(), - namespace: content.namespace.clone(), - source_hash: content.content_hash.clone(), - layer: 0, - parent_id: None, - children_ids: vec![], - keywords: vec![], - metadata, - } - }) - .collect() -} - -/// Simple chunking with overlap. -fn split_into_chunks(text: &str, target_size: usize, overlap: usize) -> Vec { - let mut char_offsets: Vec = text.char_indices().map(|(byte_idx, _)| byte_idx).collect(); - let len = char_offsets.len(); - - if len <= target_size { - return vec![text.to_string()]; - } - - char_offsets.push(text.len()); - - let mut chunks = Vec::new(); - let mut start = 0; - - while start < len { - let end = (start + target_size).min(len); - let start_byte = char_offsets[start]; - let end_byte = char_offsets[end]; - chunks.push(text[start_byte..end_byte].to_string()); - if end >= len { - break; - } - - start = end.saturating_sub(overlap); + if requested_slice_mode == SliceMode::Flat { + return ChunkerKind::Flat; } - chunks + detect_default_chunker(source_path, namespace) } // ============================================================================= @@ -1383,20 +1382,41 @@ async fn stage_store_chunks( let content_hash = embedded_file.content_hash.clone(); let mut stored_for_file = 0usize; let mut storage_failed = false; + let mut stored_doc_refs: Vec<(String, String)> = Vec::new(); while !embedded_file.chunks.is_empty() { let take = embedded_file.chunks.len().min(STORAGE_BATCH_SIZE); let batch: Vec = embedded_file.chunks.drain(..take).collect(); + let batch_refs: Vec<(String, String)> = batch + .iter() + .map(|embedded| (embedded.chunk.namespace.clone(), embedded.chunk.id.clone())) + .collect(); match store_batch(&storage, batch).await { - Ok(count) => stored_for_file += count, + Ok(count) => { + stored_for_file += count; + stored_doc_refs.extend(batch_refs); + } Err(err) => { + let (rolled_back, rollback_failures) = + rollback_stored_file_chunks(&storage, &stored_doc_refs).await; + let rollback_suffix = if rollback_failures == 0 { + format!( + "rolled back {} previously stored chunks for file", + rolled_back + ) + } else { + format!( + "rolled back {} previously stored chunks for file, {} rollback deletes failed", + rolled_back, rollback_failures + ) + }; error!("Storage batch failed for {:?}: {}", path, err); observer .emit(PipelineEvent::Error { path: Some(path.clone()), stage: "storage", - message: err.to_string(), + message: format!("{err}; {rollback_suffix}"), }) .await; storage_failed = true; @@ -1419,13 +1439,41 @@ async fn stage_store_chunks( info!("Storage stage complete"); } +async fn rollback_stored_file_chunks( + storage: &StorageManager, + stored_doc_refs: &[(String, String)], +) -> (usize, usize) { + let mut deleted = 0usize; + let mut failures = 0usize; + + for (namespace, id) in stored_doc_refs.iter().rev() { + match storage.delete_document(namespace, id).await { + Ok(count) => deleted += count, + Err(err) => { + failures += 1; + warn!( + "Failed to roll back partially stored chunk {}/{}: {}", + namespace, id, err + ); + } + } + } + + (deleted, failures) +} + /// Store a batch of embedded chunks. +/// +/// Writes BOTH per-chunk `content_hash` (for chunk-level dedup, distinguishes +/// outer/middle/inner/core slices of one source) AND `source_hash` (same value +/// across all four onion layers, for pre-index source dedup). async fn store_batch(storage: &StorageManager, batch: Vec) -> Result { let count = batch.len(); let documents: Vec = batch .into_iter() .map(|embedded| { + let source_hash = Some(embedded.chunk.source_hash); if embedded.chunk.layer > 0 { ChromaDocument { id: embedded.chunk.id, @@ -1437,16 +1485,18 @@ async fn store_batch(storage: &StorageManager, batch: Vec) -> Res parent_id: embedded.chunk.parent_id, children_ids: embedded.chunk.children_ids, keywords: embedded.chunk.keywords, - content_hash: Some(embedded.chunk.source_hash), + content_hash: Some(embedded.chunk.chunk_hash), + source_hash, } } else { - ChromaDocument::new_flat_with_hash( + ChromaDocument::new_flat_with_hashes( embedded.chunk.id, embedded.chunk.namespace, embedded.embedding, embedded.chunk.metadata, embedded.chunk.content, - embedded.chunk.source_hash, + embedded.chunk.chunk_hash, + source_hash, ) } }) @@ -1472,9 +1522,13 @@ pub async fn run_pipeline( config: PipelineConfig, ) -> Result { let total_files = files.len(); + let discovered_files = config + .discovered_files + .max(total_files.saturating_add(config.resumed_files)) + .max(total_files); info!( - "Starting pipeline: {} files, mode: {:?}", - total_files, config.slice_mode + "Starting pipeline: {} scheduled files ({} discovered, {} resumed), mode: {:?}", + total_files, discovered_files, config.resumed_files, config.slice_mode ); let base_client = { @@ -1502,6 +1556,8 @@ pub async fn run_pipeline( }; let observer = PipelineObserver::new( total_files, + discovered_files, + config.resumed_files, config.event_sender.clone(), initial_runtime, governor_mode, @@ -1517,18 +1573,29 @@ pub async fn run_pipeline( let storage_for_storage = storage; let ns_for_reader = namespace.clone(); let slice_mode = config.slice_mode; + let chunker = config.chunker; + let outer_synthesis = config.outer_synthesis.clone(); let dedup_enabled = config.dedup_enabled; + let preprocess_config = config.preprocess_config.clone(); let reader_handle = tokio::spawn(stage_read_files( files, ns_for_reader, storage_for_reader, dedup_enabled, + preprocess_config, tx1, observer.clone(), )); - let chunker_handle = tokio::spawn(stage_chunk_content(rx1, tx2, slice_mode, observer.clone())); + let chunker_handle = tokio::spawn(stage_chunk_content( + rx1, + tx2, + slice_mode, + chunker, + outer_synthesis, + observer.clone(), + )); let embedder_handle = tokio::spawn(stage_embed_chunks( rx2, tx3, @@ -1564,11 +1631,12 @@ pub async fn run_pipeline( mod tests { use super::*; use std::path::PathBuf; + use tempfile::TempDir; #[test] fn test_split_into_chunks_short_text() { let text = "Hello world"; - let chunks = split_into_chunks(text, 100, 20); + let chunks = crate::rag::provider::split_into_chunks(text, 100, 20); assert_eq!(chunks.len(), 1); assert_eq!(chunks[0], "Hello world"); } @@ -1576,7 +1644,7 @@ mod tests { #[test] fn test_split_into_chunks_with_overlap() { let text = "abcdefghijklmnopqrstuvwxyz"; - let chunks = split_into_chunks(text, 10, 3); + let chunks = crate::rag::provider::split_into_chunks(text, 10, 3); assert!(chunks.len() > 1); assert_eq!(chunks[0].len(), 10); assert!(chunks[0].ends_with(&chunks[1][..3])); @@ -1587,16 +1655,92 @@ mod tests { let config = PipelineConfig::default(); assert_eq!(config.reader_buffer, CHANNEL_BUFFER_SIZE); assert_eq!(config.slice_mode, SliceMode::default()); + assert!(matches!( + config.outer_synthesis, + crate::rag::OuterSynthesis::Keyword + )); assert!(config.dedup_enabled); + assert!(config.preprocess_config.is_none()); assert_eq!(config.embed_concurrency, 1); assert!(config.governor.is_none()); assert!(config.event_sender.is_none()); } + #[tokio::test] + async fn pipeline_reader_applies_preprocess_after_raw_source_hashing() { + let tmp = TempDir::new().expect("temp dir"); + let source = tmp.path().join("conversation.md"); + let raw = "Operator decision: ship flat memory.\n\nsession_id: 123e4567-e89b-12d3-a456-426614174000\n"; + std::fs::write(&source, raw).expect("write source"); + + let storage = Arc::new( + StorageManager::new_lance_only(tmp.path().join("lancedb").to_str().unwrap()) + .await + .expect("storage"), + ); + storage.ensure_collection().await.expect("collection"); + let observer = PipelineObserver::new( + 1, + 1, + 0, + None, + EmbedRuntimeSettings::new(8_192, 16, 1), + "fixed".to_string(), + "operator-configured limits".to_string(), + ); + let (tx, mut rx) = mpsc::channel(1); + + stage_read_files( + vec![source], + "kb:test".to_string(), + storage, + false, + Some(PreprocessingConfig { + remove_metadata: true, + min_content_length: 1, + ..Default::default() + }), + tx, + observer, + ) + .await; + + let content = rx.recv().await.expect("preprocessed file content"); + assert_eq!(content.content_hash, crate::rag::compute_content_hash(raw)); + assert!(content.text.contains("Operator decision")); + assert!(!content.text.contains("123e4567")); + } + + #[test] + fn pipeline_auto_chunker_honors_explicit_flat_slice_mode() { + let selected = select_pipeline_chunker( + None, + SliceMode::Flat, + Path::new("/Users/silver/memex-index-staging/miksa-clean/conversation.md"), + "kb:mikserka", + ); + + assert_eq!(selected, ChunkerKind::Flat); + } + + #[test] + fn pipeline_auto_chunker_keeps_transcript_routing_for_onion_modes() { + let selected = select_pipeline_chunker( + None, + SliceMode::Onion, + Path::new("/Users/polyversai/.aicx/store/Loctree/session.md"), + "aicx", + ); + + assert_eq!(selected, ChunkerKind::Aicx); + } + #[tokio::test] async fn test_pipeline_observer_tracks_snapshot_and_failures() { let observer = PipelineObserver::new( 3, + 3, + 0, None, EmbedRuntimeSettings::new(8_192, 16, 2), "fixed".to_string(), @@ -1665,6 +1809,8 @@ mod tests { let result = observer.result().await; assert_eq!(result.stats.total_files, 3); + assert_eq!(result.stats.discovered_files, 3); + assert_eq!(result.stats.resumed_files, 0); assert_eq!(result.stats.files_read, 1); assert_eq!(result.stats.files_skipped, 1); assert_eq!(result.stats.files_committed, 1); @@ -1741,6 +1887,8 @@ mod tests { fn test_snapshot_to_stats_carries_runtime_truth() { let snapshot = PipelineSnapshot { total_files: 5, + discovered_files: 7, + resumed_files: 2, files_read: 4, files_skipped: 1, files_committed: 2, @@ -1754,6 +1902,8 @@ mod tests { let stats = snapshot.to_stats(); assert_eq!(stats.total_files, 5); + assert_eq!(stats.discovered_files, 7); + assert_eq!(stats.resumed_files, 2); assert_eq!(stats.files_read, 4); assert_eq!(stats.files_skipped, 1); assert_eq!(stats.files_committed, 2); @@ -1763,4 +1913,63 @@ mod tests { assert_eq!(stats.chunks_stored, 8); assert_eq!(stats.errors, 3); } + + #[tokio::test] + async fn test_rollback_stored_file_chunks_removes_partial_file_writes() { + let tmp = TempDir::new().expect("temp dir"); + let db_path = tmp.path().join("lancedb"); + let storage = StorageManager::new_lance_only(db_path.to_str().unwrap()) + .await + .expect("storage"); + storage.ensure_collection().await.expect("collection"); + + let namespace = "rollback-ns".to_string(); + let doc_a = ChromaDocument::new_flat_with_hash( + "chunk-a".to_string(), + namespace.clone(), + vec![0.1_f32; 8], + serde_json::json!({"path": "doc-a.md"}), + "alpha".to_string(), + "file-hash".to_string(), + ); + let doc_b = ChromaDocument::new_flat_with_hash( + "chunk-b".to_string(), + namespace.clone(), + vec![0.2_f32; 8], + serde_json::json!({"path": "doc-a.md"}), + "beta".to_string(), + "file-hash".to_string(), + ); + + storage + .add_to_store(vec![doc_a, doc_b]) + .await + .expect("seed partial writes"); + + let (deleted, failures) = rollback_stored_file_chunks( + &storage, + &[ + (namespace.clone(), "chunk-a".to_string()), + (namespace.clone(), "chunk-b".to_string()), + ], + ) + .await; + + assert_eq!(deleted, 2); + assert_eq!(failures, 0); + assert!( + storage + .get_document(&namespace, "chunk-a") + .await + .expect("lookup chunk-a") + .is_none() + ); + assert!( + storage + .get_document(&namespace, "chunk-b") + .await + .expect("lookup chunk-b") + .is_none() + ); + } } diff --git a/crates/rust-memex/src/rag/provider.rs b/crates/rust-memex/src/rag/provider.rs new file mode 100644 index 0000000..0509d08 --- /dev/null +++ b/crates/rust-memex/src/rag/provider.rs @@ -0,0 +1,595 @@ +use anyhow::{Result, anyhow}; +use chrono::{TimeZone, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::future::Future; +use std::path::Path; +use std::pin::Pin; + +use crate::rag::{ + OnionSlice, OnionSliceConfig, OuterSynthesis, SliceMode, create_onion_slices_async, + create_onion_slices_fast_async, +}; +use crate::rag::{compute_content_hash, pipeline::Chunk, pipeline::FileContent}; + +pub type ChunkProviderFuture<'a> = Pin>> + Send + 'a>>; + +/// Options shared by all chunk providers. +#[derive(Debug, Clone)] +pub struct ChunkOpts { + pub chunker: ChunkerKind, + pub slice_mode: SliceMode, + pub outer_synthesis: OuterSynthesis, + pub flat_window: usize, + pub flat_overlap: usize, +} + +impl ChunkOpts { + pub fn new( + chunker: ChunkerKind, + slice_mode: SliceMode, + outer_synthesis: OuterSynthesis, + ) -> Self { + Self { + chunker, + slice_mode, + outer_synthesis, + flat_window: 512, + flat_overlap: 128, + } + } +} + +/// Trait abstracting chunk production strategies. +pub trait ChunkProvider: Send + Sync { + fn name(&self) -> &'static str; + fn chunk<'a>(&'a self, doc: &'a FileContent, opts: &'a ChunkOpts) -> ChunkProviderFuture<'a>; +} + +/// Chunker strategy exposed through CLI/API routing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "cli", derive(clap::ValueEnum))] +#[serde(rename_all = "kebab-case")] +pub enum ChunkerKind { + Aicx, + Onion, + Flat, +} + +impl ChunkerKind { + pub fn name(self) -> &'static str { + match self { + Self::Aicx => "aicx", + Self::Onion => "onion", + Self::Flat => "flat", + } + } + + pub fn into_provider(self) -> Box { + match self { + Self::Aicx => Box::new(AicxChunkProvider::default()), + Self::Onion => Box::new(OnionChunkProvider), + Self::Flat => Box::new(FlatChunkProvider { + window: 512, + overlap: 128, + }), + } + } + + pub fn slice_mode(self, requested: SliceMode) -> SliceMode { + match self { + Self::Aicx | Self::Flat => SliceMode::Flat, + Self::Onion => match requested { + SliceMode::OnionFast => SliceMode::OnionFast, + _ => SliceMode::Onion, + }, + } + } +} + +impl std::str::FromStr for ChunkerKind { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + match s.to_ascii_lowercase().as_str() { + "aicx" => Ok(Self::Aicx), + "onion" => Ok(Self::Onion), + "flat" => Ok(Self::Flat), + other => Err(format!( + "Invalid chunker: '{}'. Use 'aicx', 'onion', or 'flat'", + other + )), + } + } +} + +impl std::fmt::Display for ChunkerKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.name()) + } +} + +/// Existing onion-slicer logic. Deprecated for transcript-shaped content. +pub struct OnionChunkProvider; + +/// Frame-aware transcript chunker via `aicx-parser`. +#[derive(Default)] +pub struct AicxChunkProvider { + config: aicx_parser::ChunkerConfig, +} + +/// Simple fixed-size sliding window with configurable overlap. +pub struct FlatChunkProvider { + window: usize, + overlap: usize, +} + +impl ChunkProvider for OnionChunkProvider { + fn name(&self) -> &'static str { + "onion" + } + + fn chunk<'a>(&'a self, doc: &'a FileContent, opts: &'a ChunkOpts) -> ChunkProviderFuture<'a> { + Box::pin(async move { + let metadata = base_metadata(doc, opts.chunker, opts.slice_mode); + let config = OnionSliceConfig { + outer_synthesis: opts.outer_synthesis.clone(), + ..OnionSliceConfig::default() + }; + let slices = match opts.slice_mode { + SliceMode::OnionFast => { + create_onion_slices_fast_async(&doc.text, &metadata, &config).await + } + SliceMode::Onion | SliceMode::Flat => { + create_onion_slices_async(&doc.text, &metadata, &config).await + } + }; + + Ok(slices_to_chunks(slices, doc, metadata)) + }) + } +} + +impl ChunkProvider for AicxChunkProvider { + fn name(&self) -> &'static str { + "aicx" + } + + fn chunk<'a>(&'a self, doc: &'a FileContent, opts: &'a ChunkOpts) -> ChunkProviderFuture<'a> { + Box::pin(async move { + let entries = doc_to_timeline_entries(doc)?; + let project = project_label(&doc.namespace); + let agent = first_agent(&entries).unwrap_or("rust-memex"); + let aicx_chunks = + aicx_parser::chunker::chunk_entries(&entries, &project, agent, &self.config); + Ok(aicx_chunks + .into_iter() + .map(|chunk| memex_chunk_from_aicx(chunk, doc, opts)) + .collect()) + }) + } +} + +impl ChunkProvider for FlatChunkProvider { + fn name(&self) -> &'static str { + "flat" + } + + fn chunk<'a>(&'a self, doc: &'a FileContent, opts: &'a ChunkOpts) -> ChunkProviderFuture<'a> { + Box::pin(async move { + let window = opts.flat_window.max(1).max(self.window); + let overlap = opts + .flat_overlap + .min(window.saturating_sub(1)) + .max(self.overlap.min(window.saturating_sub(1))); + let metadata = base_metadata(doc, opts.chunker, SliceMode::Flat); + Ok(create_flat_chunks( + &doc.text, doc, metadata, window, overlap, + )) + }) + } +} + +pub fn detect_default_chunker(source_path: &Path, namespace: &str) -> ChunkerKind { + let path_str = source_path.to_string_lossy(); + if namespace.starts_with("kb:transcripts") + || namespace.starts_with("aicx") + || namespace.starts_with("klaudiusz-") + || path_str.contains("__clean.md") + || path_str.contains("/.aicx/store/") + || path_str.contains("/transcripts/") + { + ChunkerKind::Aicx + } else { + ChunkerKind::Onion + } +} + +fn base_metadata(doc: &FileContent, chunker: ChunkerKind, slice_mode: SliceMode) -> Value { + let mut metadata = json!({ + "path": doc.path.to_str(), + "content_hash": &doc.content_hash, + "source_hash": &doc.content_hash, + "chunker": chunker.name(), + "slice_mode": match slice_mode { + SliceMode::Onion => "onion", + SliceMode::OnionFast => "onion-fast", + SliceMode::Flat => "flat", + }, + }); + + if chunker == ChunkerKind::Aicx + && let Value::Object(ref mut map) = metadata + { + map.insert("format".to_string(), json!("markdown_transcript")); + map.insert("type".to_string(), json!("conversation")); + } + + metadata +} + +fn slices_to_chunks( + slices: Vec, + content: &FileContent, + base_metadata: Value, +) -> Vec { + slices + .into_iter() + .map(|slice| { + let chunk_hash = compute_content_hash(&slice.content); + let mut metadata = base_metadata.clone(); + if let Value::Object(ref mut map) = metadata { + map.insert("chunk_hash".to_string(), json!(&chunk_hash)); + map.insert("layer".to_string(), json!(slice.layer.name())); + map.insert("keywords".to_string(), json!(slice.keywords)); + } + + Chunk { + id: slice.id, + content: slice.content, + source_path: content.path.clone(), + namespace: content.namespace.clone(), + chunk_hash, + source_hash: content.content_hash.clone(), + layer: slice.layer.as_u8(), + parent_id: slice.parent_id, + children_ids: slice.children_ids, + keywords: slice.keywords, + metadata, + } + }) + .collect() +} + +fn create_flat_chunks( + text: &str, + content: &FileContent, + base_metadata: Value, + window: usize, + overlap: usize, +) -> Vec { + let chunks = split_into_chunks(text, window, overlap); + let total_chunks = chunks.len(); + + chunks + .into_iter() + .enumerate() + .map(|(idx, chunk_text)| { + let chunk_hash = compute_content_hash(&chunk_text); + let mut metadata = base_metadata.clone(); + if let Value::Object(ref mut map) = metadata { + map.insert("chunk_index".to_string(), json!(idx)); + map.insert("total_chunks".to_string(), json!(total_chunks)); + map.insert("chunk_hash".to_string(), json!(&chunk_hash)); + } + + let id = format!( + "{}_{}_{}", + content.path.to_str().unwrap_or("unknown"), + content.content_hash.get(..8).unwrap_or(""), + idx + ); + + Chunk { + id, + content: chunk_text, + source_path: content.path.clone(), + namespace: content.namespace.clone(), + chunk_hash, + source_hash: content.content_hash.clone(), + layer: 0, + parent_id: None, + children_ids: vec![], + keywords: vec![], + metadata, + } + }) + .collect() +} + +pub(crate) fn split_into_chunks(text: &str, target_size: usize, overlap: usize) -> Vec { + let mut char_offsets: Vec = text.char_indices().map(|(byte_idx, _)| byte_idx).collect(); + let len = char_offsets.len(); + + if len <= target_size { + return vec![text.to_string()]; + } + + char_offsets.push(text.len()); + + let mut chunks = Vec::new(); + let mut start = 0; + + while start < len { + let end = (start + target_size).min(len); + let start_byte = char_offsets[start]; + let end_byte = char_offsets[end]; + chunks.push(text[start_byte..end_byte].to_string()); + + if end >= len { + break; + } + + start = end.saturating_sub(overlap); + } + + chunks +} + +fn doc_to_timeline_entries(doc: &FileContent) -> Result> { + let timestamp = Utc + .timestamp_opt(0, 0) + .single() + .ok_or_else(|| anyhow!("failed to construct epoch timestamp"))?; + let session_id = compute_content_hash(&doc.text) + .get(..12) + .unwrap_or("session") + .to_string(); + let cwd = doc + .path + .parent() + .and_then(Path::to_str) + .map(ToString::to_string); + let preamble = extract_preamble(&doc.text); + let mut entries = Vec::new(); + let mut current_role: Option = None; + let mut current_body = String::new(); + + for line in doc.text.lines() { + if let Some(role) = role_heading(line) { + push_entry( + &mut entries, + current_role.take(), + &mut current_body, + timestamp, + &session_id, + cwd.clone(), + ); + current_role = Some(role.to_string()); + } else { + if !current_body.is_empty() { + current_body.push('\n'); + } + current_body.push_str(line); + } + } + + push_entry( + &mut entries, + current_role, + &mut current_body, + timestamp, + &session_id, + cwd, + ); + + if let Some(preamble) = preamble + && let Some(first) = entries.first_mut() + && !first.message.starts_with("---") + { + first.message = format!("{}\n\n{}", preamble.trim_end(), first.message.trim_start()); + } + + if entries.is_empty() { + entries.push(aicx_parser::TimelineEntry { + timestamp, + agent: "rust-memex".to_string(), + session_id, + role: "assistant".to_string(), + message: doc.text.clone(), + frame_kind: Some(aicx_parser::FrameKind::AgentReply), + branch: None, + cwd: doc + .path + .parent() + .and_then(Path::to_str) + .map(ToString::to_string), + }); + } + + Ok(entries) +} + +fn extract_preamble(text: &str) -> Option { + let mut lines = Vec::new(); + for line in text.lines() { + if role_heading(line).is_some() { + break; + } + lines.push(line); + } + let preamble = lines.join("\n"); + (!preamble.trim().is_empty()).then_some(preamble) +} + +fn push_entry( + entries: &mut Vec, + role: Option, + body: &mut String, + timestamp: chrono::DateTime, + session_id: &str, + cwd: Option, +) { + let Some(role) = role else { + body.clear(); + return; + }; + let message = body.trim().to_string(); + body.clear(); + if message.is_empty() { + return; + } + let frame_kind = match role.as_str() { + "user" => Some(aicx_parser::FrameKind::UserMsg), + "assistant" => Some(aicx_parser::FrameKind::AgentReply), + "tool" => Some(aicx_parser::FrameKind::ToolCall), + _ => None, + }; + entries.push(aicx_parser::TimelineEntry { + timestamp, + agent: "rust-memex".to_string(), + session_id: session_id.to_string(), + role, + message, + frame_kind, + branch: None, + cwd, + }); +} + +fn role_heading(line: &str) -> Option<&'static str> { + let lowered = line.trim().to_ascii_lowercase(); + match lowered.as_str() { + "## user" | "### user" | "[user]" | "user request:" => Some("user"), + "## assistant" | "### assistant" | "[assistant]" | "assistant response:" => { + Some("assistant") + } + "## tool" | "### tool" | "[tool]" | "tool:" | "tool result:" => Some("tool"), + _ => None, + } +} + +fn first_agent(entries: &[aicx_parser::TimelineEntry]) -> Option<&str> { + entries.first().map(|entry| entry.agent.as_str()) +} + +fn project_label(namespace: &str) -> String { + namespace + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '_' + } + }) + .collect() +} + +fn memex_chunk_from_aicx( + chunk: aicx_parser::Chunk, + content: &FileContent, + opts: &ChunkOpts, +) -> Chunk { + let chunk_hash = compute_content_hash(&chunk.text); + let id_prefix = content.content_hash.get(..8).unwrap_or("aicx"); + let mut metadata = base_metadata(content, opts.chunker, SliceMode::Flat); + if let Value::Object(ref mut map) = metadata { + map.insert("aicx_chunk_id".to_string(), json!(&chunk.id)); + map.insert("aicx_project".to_string(), json!(&chunk.project)); + map.insert("aicx_agent".to_string(), json!(&chunk.agent)); + map.insert("aicx_date".to_string(), json!(&chunk.date)); + map.insert("aicx_session_id".to_string(), json!(&chunk.session_id)); + map.insert("aicx_kind".to_string(), json!(chunk.kind)); + map.insert("aicx_frame_kind".to_string(), json!(chunk.frame_kind)); + map.insert("aicx_msg_start".to_string(), json!(chunk.msg_range.0)); + map.insert("aicx_msg_end".to_string(), json!(chunk.msg_range.1)); + map.insert("token_estimate".to_string(), json!(chunk.token_estimate)); + map.insert("highlights".to_string(), json!(&chunk.highlights)); + map.insert("chunk_hash".to_string(), json!(&chunk_hash)); + if let Some(cwd) = &chunk.cwd { + map.insert("aicx_cwd".to_string(), json!(cwd)); + } + if let Some(run_id) = &chunk.run_id { + map.insert("run_id".to_string(), json!(run_id)); + } + if let Some(prompt_id) = &chunk.prompt_id { + map.insert("prompt_id".to_string(), json!(prompt_id)); + } + if let Some(model) = &chunk.agent_model { + map.insert("agent_model".to_string(), json!(model)); + } + } + + Chunk { + id: format!("{id_prefix}::{}", chunk.id), + content: chunk.text, + source_path: content.path.clone(), + namespace: content.namespace.clone(), + chunk_hash, + source_hash: content.content_hash.clone(), + layer: 0, + parent_id: None, + children_ids: vec![], + keywords: chunk.highlights, + metadata, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn doc(text: &str, namespace: &str, path: &str) -> FileContent { + FileContent { + path: PathBuf::from(path), + text: text.to_string(), + namespace: namespace.to_string(), + content_hash: compute_content_hash(text), + } + } + + #[test] + fn default_chunker_routes_transcripts_to_aicx() { + assert_eq!( + detect_default_chunker(Path::new("/tmp/transcripts/sample.md"), "kb:any"), + ChunkerKind::Aicx + ); + assert_eq!( + detect_default_chunker(Path::new("/tmp/readme.md"), "kb:docs"), + ChunkerKind::Onion + ); + } + + #[tokio::test] + async fn aicx_provider_chunks_markdown_transcript() { + let doc = doc( + "## user\nWhat is the meaning of life?\n\n## assistant\nBuild something useful.\n", + "kb:transcripts-test", + "/tmp/sample-transcript.md", + ); + let opts = ChunkOpts::new( + ChunkerKind::Aicx, + SliceMode::Flat, + OuterSynthesis::default(), + ); + let chunks = AicxChunkProvider::default() + .chunk(&doc, &opts) + .await + .unwrap(); + + assert!(!chunks.is_empty()); + assert_eq!(chunks[0].metadata["chunker"], "aicx"); + assert!(chunks[0].content.contains("meaning of life")); + } + + #[test] + fn split_into_chunks_preserves_overlap() { + let chunks = split_into_chunks("abcdefghijklmnopqrstuvwxyz", 10, 3); + + assert!(chunks.len() > 1); + assert_eq!(chunks[0].len(), 10); + assert!(chunks[0].ends_with(&chunks[1][..3])); + } +} diff --git a/src/rag/structured.rs b/crates/rust-memex/src/rag/structured.rs similarity index 75% rename from src/rag/structured.rs rename to crates/rust-memex/src/rag/structured.rs index ffd38a8..ed093a9 100644 --- a/src/rag/structured.rs +++ b/crates/rust-memex/src/rag/structured.rs @@ -108,9 +108,20 @@ fn parse_markdown_transcript_blocks(content: &str) -> Vec { let mut blocks = Vec::new(); let mut current_role: Option = None; let mut current_lines = Vec::new(); + // Track fenced-code-block state. Spec P2 acceptance: "Code blocks: 0% + // rozcięć w środku ` ``` `" — a heading-shaped line inside an open fence + // (e.g. an example transcript pasted into a user prompt) must NOT split + // the parent role block. + let mut in_fence = false; for line in content.lines() { - if let Some(role) = parse_markdown_heading(line) { + if is_fence_marker(line) { + in_fence = !in_fence; + current_lines.push(line.to_string()); + continue; + } + + if !in_fence && let Some(role) = parse_markdown_heading(line) { if let Some(existing_role) = current_role.take() { push_raw_block(&mut blocks, existing_role, ¤t_lines.join("\n")); } @@ -133,6 +144,16 @@ fn parse_markdown_transcript_blocks(content: &str) -> Vec { blocks } +/// Recognises an opening or closing fenced-code-block delimiter. Toggles +/// `in_fence` in `parse_markdown_transcript_blocks`. Conservative: matches any +/// line whose first non-whitespace characters are 3+ backticks or 3+ tildes, +/// covering CommonMark info strings (```rust, ```bash, etc.) and matched +/// closers without language tags. +fn is_fence_marker(line: &str) -> bool { + let trimmed = line.trim_start(); + trimmed.starts_with("```") || trimmed.starts_with("~~~") +} + fn push_raw_block(blocks: &mut Vec, role: String, content: &str) { let trimmed = content.trim(); if trimmed.is_empty() { @@ -147,15 +168,49 @@ fn push_raw_block(blocks: &mut Vec, role: String, content: &str) { fn parse_markdown_heading(line: &str) -> Option<&'static str> { let trimmed = line.trim(); + // Original AI Chronicles-style headings. if trimmed.eq_ignore_ascii_case("User request:") { - Some("user") - } else if trimmed.eq_ignore_ascii_case("Assistant response:") { - Some("assistant") - } else if trimmed.eq_ignore_ascii_case("Reasoning focus:") { - Some("reasoning") - } else { - None + return Some("user"); + } + if trimmed.eq_ignore_ascii_case("Assistant response:") { + return Some("assistant"); } + if trimmed.eq_ignore_ascii_case("Reasoning focus:") { + return Some("reasoning"); + } + + // Claude Code / Codex transcript format: lines like + // `## user`, `## assistant`, `## tool`, `## system`, + // `### User`, `### Assistant`, `[user]`, `[assistant]`. + let stripped = trimmed + .trim_start_matches('#') + .trim_start_matches(['[', '*']) + .trim_end_matches([':', ']']) + .trim(); + + if stripped.eq_ignore_ascii_case("user") || stripped.eq_ignore_ascii_case("human") { + return Some("user"); + } + if stripped.eq_ignore_ascii_case("assistant") + || stripped.eq_ignore_ascii_case("model") + || stripped.eq_ignore_ascii_case("ai") + { + return Some("assistant"); + } + if stripped.eq_ignore_ascii_case("system") || stripped.eq_ignore_ascii_case("system context") { + return Some("system"); + } + if stripped.eq_ignore_ascii_case("tool") + || stripped.eq_ignore_ascii_case("tool output") + || stripped.eq_ignore_ascii_case("tool result") + { + return Some("tool"); + } + if stripped.eq_ignore_ascii_case("reasoning") || stripped.eq_ignore_ascii_case("thought") { + return Some("reasoning"); + } + + None } fn normalize_role_key(role: &str) -> String { @@ -631,7 +686,7 @@ fn create_structured_outer_core_slices( #[cfg(test)] mod tests { - use super::{create_structured_outer, parse_blocks}; + use super::{create_structured_outer, is_fence_marker, parse_blocks}; use serde_json::json; #[test] @@ -653,4 +708,98 @@ mod tests { assert!(outer.contains("Next:")); assert!(!outer.starts_with('[')); } + + #[test] + fn fence_marker_detects_backtick_and_tilde_openers() { + assert!(is_fence_marker("```")); + assert!(is_fence_marker("```rust")); + assert!(is_fence_marker(" ```bash")); + assert!(is_fence_marker("~~~")); + assert!(is_fence_marker("~~~markdown")); + assert!(!is_fence_marker("`single`")); + assert!(!is_fence_marker("``two``")); + assert!(!is_fence_marker("## user")); + assert!(!is_fence_marker("")); + } + + #[test] + fn parse_blocks_keeps_fenced_pseudo_headings_inside_user_turn() { + // Spec P2 acceptance: fenced ` ``` ` blocks must be atomic. A user + // pasting an example transcript into their question contains + // heading-shaped lines (`## assistant`, `## user`) inside a fence — + // the parser must NOT treat them as turn boundaries. + let metadata = json!({ + "type": "transcript_turn", + "format": "markdown_transcript", + }); + let content = "## user\n\ + Look at this snippet from yesterday's chat:\n\ + ```\n\ + ## assistant\n\ + fenced pseudo-response\n\ + ## user\n\ + fenced pseudo-followup\n\ + ```\n\ + Why does it look weird?\n\ + \n\ + ## assistant\n\ + Because the model echoed an example transcript verbatim.\n"; + + let blocks = parse_blocks(content, &metadata); + + assert_eq!( + blocks.len(), + 2, + "expected exactly two blocks (user, assistant); fenced pseudo-headings must not split the user turn" + ); + assert_eq!(blocks[0].primary_label, "Request"); + assert!( + blocks[0].content.contains("fenced pseudo-response"), + "user block lost its fenced example content: {:?}", + blocks[0].content + ); + assert!( + blocks[0].content.contains("fenced pseudo-followup"), + "user block lost its fenced example content: {:?}", + blocks[0].content + ); + assert!( + blocks[0].content.contains("Why does it look weird?"), + "user block dropped the trailing prose: {:?}", + blocks[0].content + ); + assert_eq!(blocks[1].primary_label, "Response"); + assert!( + blocks[1].content.contains("echoed an example transcript"), + "assistant block missing real response: {:?}", + blocks[1].content + ); + } + + #[test] + fn parse_blocks_keeps_fenced_pseudo_headings_inside_tilde_fence() { + // Same invariant as above, exercised against `~~~` fences (the second + // CommonMark fence form). Same toggle path — symmetry test. + let metadata = json!({ + "type": "transcript_turn", + "format": "markdown_transcript", + }); + let content = "## user\n\ + Tilde-fenced sample:\n\ + ~~~\n\ + ## assistant\n\ + still inside the fence\n\ + ~~~\n\ + \n\ + ## assistant\n\ + The real reply.\n"; + + let blocks = parse_blocks(content, &metadata); + + assert_eq!(blocks.len(), 2); + assert_eq!(blocks[0].primary_label, "Request"); + assert!(blocks[0].content.contains("still inside the fence")); + assert_eq!(blocks[1].primary_label, "Response"); + assert!(blocks[1].content.contains("The real reply.")); + } } diff --git a/crates/rust-memex/src/recovery.rs b/crates/rust-memex/src/recovery.rs new file mode 100644 index 0000000..3b745cd --- /dev/null +++ b/crates/rust-memex/src/recovery.rs @@ -0,0 +1,310 @@ +use anyhow::{Result, anyhow}; +use memex_contracts::progress::{CompactProgress, MergeProgress, RepairResult}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use crate::{ + BM25Config, BM25Index, CrossStoreRecoveryReport, GcConfig, GcStats, StorageManager, TableStats, + inspect_cross_store_recovery, path_utils, repair_cross_store_recovery, +}; + +#[derive(Debug, Clone)] +pub struct MergeExecution { + pub progress: MergeProgress, + pub target_path: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct RepairExecution { + pub result: RepairResult, + pub report: CrossStoreRecoveryReport, +} + +#[derive(Debug, Clone)] +pub struct MaintenanceExecution { + pub progress: CompactProgress, + pub pre_stats: Option, + pub post_stats: Option, + pub gc_stats: Option, +} + +pub async fn merge_databases( + source_paths: Vec, + target_path: PathBuf, + dedup: bool, + namespace_prefix: Option, + dry_run: bool, +) -> Result { + let mut validated_sources = Vec::new(); + let mut progress = MergeProgress::default(); + let mut namespaces = HashSet::new(); + + for source in &source_paths { + let source_str = source.to_str().unwrap_or(""); + match path_utils::sanitize_existing_path(source_str) { + Ok(validated) => validated_sources.push(validated), + Err(_) => progress.errors += 1, + } + } + + if validated_sources.is_empty() { + return Err(anyhow!("No valid source databases found")); + } + + let validated_target = path_utils::sanitize_new_path(target_path.to_str().unwrap_or(""))?; + let target_storage = if dry_run { + None + } else { + if let Some(parent) = validated_target.parent() { + std::fs::create_dir_all(parent)?; + } + Some(StorageManager::new_lance_only(validated_target.to_str().unwrap_or("")).await?) + }; + + let mut seen_hashes = HashSet::new(); + if dedup + && let Some(ref target) = target_storage + && let Ok(existing_docs) = target.all_documents(None, 100_000).await + { + for doc in existing_docs { + if let Some(hash) = doc.content_hash { + seen_hashes.insert(hash); + } + } + } + + for source_path in &validated_sources { + let source_storage = + match StorageManager::new_lance_only(source_path.to_str().unwrap_or("")).await { + Ok(storage) => storage, + Err(_) => { + progress.errors += 1; + continue; + } + }; + + let source_docs = match source_storage.all_documents(None, 100_000).await { + Ok(docs) => docs, + Err(_) => { + progress.errors += 1; + continue; + } + }; + + progress.total_docs += source_docs.len(); + + let mut docs_by_namespace = std::collections::HashMap::new(); + for doc in source_docs { + docs_by_namespace + .entry(doc.namespace.clone()) + .or_insert_with(Vec::new) + .push(doc); + } + + for (namespace, docs) in docs_by_namespace { + let target_namespace = if let Some(ref prefix) = namespace_prefix { + format!("{prefix}{namespace}") + } else { + namespace + }; + namespaces.insert(target_namespace.clone()); + + let mut batch = Vec::new(); + for doc in docs { + if dedup && let Some(ref hash) = doc.content_hash { + if seen_hashes.contains(hash) { + progress.docs_skipped += 1; + continue; + } + seen_hashes.insert(hash.clone()); + } + + batch.push(crate::ChromaDocument { + id: doc.id, + namespace: target_namespace.clone(), + embedding: doc.embedding, + metadata: doc.metadata, + document: doc.document, + layer: doc.layer, + parent_id: doc.parent_id, + children_ids: doc.children_ids, + keywords: doc.keywords, + content_hash: doc.content_hash, + source_hash: doc.source_hash, + }); + progress.docs_copied += 1; + } + + if !dry_run + && !batch.is_empty() + && let Some(ref target) = target_storage + && target.add_to_store(batch).await.is_err() + { + progress.errors += 1; + } + } + + progress.sources_processed += 1; + } + + let mut namespaces = namespaces.into_iter().collect::>(); + namespaces.sort(); + progress.namespaces = namespaces; + + Ok(MergeExecution { + progress, + target_path: validated_target, + }) +} + +pub async fn repair_writes( + db_path: &str, + namespace: Option<&str>, + execute: bool, +) -> Result { + let storage = StorageManager::new_lance_only(db_path).await?; + let mut bm25_config = BM25Config::default().with_read_only(!execute); + if let Some(path) = sibling_bm25_path(db_path) { + bm25_config = bm25_config.with_path(path.to_string_lossy().into_owned()); + } + let bm25 = BM25Index::new(&bm25_config)?; + + let report = if execute { + repair_cross_store_recovery(&storage, &bm25, namespace).await? + } else { + inspect_cross_store_recovery(&storage, &bm25, namespace).await? + }; + + Ok(RepairExecution { + result: RepairResult { + recovery_dir: report.recovery_dir.clone(), + pending_batches: report.pending_batches, + repaired_documents: report.repaired_documents, + skipped_documents: report.skipped_documents, + batches_repaired: report.batches_repaired, + }, + report, + }) +} + +pub async fn compact_database(storage: &StorageManager) -> Result { + let started_at = Instant::now(); + let pre_stats = storage.stats().await.ok(); + let stats = storage.compact().await?; + let post_stats = storage.stats().await.ok(); + + Ok(MaintenanceExecution { + progress: CompactProgress { + phase: "compact".to_string(), + status: "complete".to_string(), + description: Some("Merging small files into larger ones".to_string()), + files_removed: stats + .compaction + .as_ref() + .map(|value| value.files_removed as u64), + files_added: stats + .compaction + .as_ref() + .map(|value| value.files_added as u64), + fragments_removed: stats + .compaction + .as_ref() + .map(|value| value.fragments_removed as u64), + fragments_added: stats + .compaction + .as_ref() + .map(|value| value.fragments_added as u64), + old_versions: None, + bytes_removed: None, + elapsed_ms: Some(started_at.elapsed().as_millis() as u64), + }, + pre_stats, + post_stats, + gc_stats: None, + }) +} + +pub async fn cleanup_versions( + storage: &StorageManager, + older_than_days: Option, +) -> Result { + let started_at = Instant::now(); + let pre_stats = storage.stats().await.ok(); + let stats = storage.cleanup(older_than_days).await?; + let post_stats = storage.stats().await.ok(); + let older_than_days = older_than_days.unwrap_or(7); + + Ok(MaintenanceExecution { + progress: CompactProgress { + phase: "cleanup".to_string(), + status: "complete".to_string(), + description: Some(format!( + "Removing old versions older than {older_than_days} days" + )), + files_removed: None, + files_added: None, + fragments_removed: None, + fragments_added: None, + old_versions: stats.prune.as_ref().map(|value| value.old_versions), + bytes_removed: stats.prune.as_ref().map(|value| value.bytes_removed), + elapsed_ms: Some(started_at.elapsed().as_millis() as u64), + }, + pre_stats, + post_stats, + gc_stats: None, + }) +} + +pub async fn collect_garbage( + storage: &StorageManager, + config: &GcConfig, +) -> Result { + let started_at = Instant::now(); + let pre_stats = storage.stats().await.ok(); + let gc_stats = storage.garbage_collect(config).await?; + let post_stats = storage.stats().await.ok(); + + Ok(MaintenanceExecution { + progress: CompactProgress { + phase: "gc".to_string(), + status: "complete".to_string(), + description: Some(gc_description(config)), + files_removed: None, + files_added: None, + fragments_removed: None, + fragments_added: None, + old_versions: None, + bytes_removed: gc_stats.bytes_freed, + elapsed_ms: Some(started_at.elapsed().as_millis() as u64), + }, + pre_stats, + post_stats, + gc_stats: Some(gc_stats), + }) +} + +fn gc_description(config: &GcConfig) -> String { + let mut actions = Vec::new(); + if config.remove_orphans { + actions.push("orphan embeddings".to_string()); + } + if config.remove_empty { + actions.push("empty namespaces".to_string()); + } + if let Some(duration) = config.older_than.as_ref() { + actions.push(format!("documents older than {} days", duration.num_days())); + } + if actions.is_empty() { + "Running garbage collection".to_string() + } else { + format!("Removing {}", actions.join(", ")) + } +} + +pub fn sibling_bm25_path(db_path: &str) -> Option { + let db_path = shellexpand::tilde(db_path).to_string(); + Path::new(&db_path) + .parent() + .map(|parent| parent.join(".bm25")) +} diff --git a/src/search/bm25.rs b/crates/rust-memex/src/search/bm25.rs similarity index 100% rename from src/search/bm25.rs rename to crates/rust-memex/src/search/bm25.rs diff --git a/src/search/hybrid.rs b/crates/rust-memex/src/search/hybrid.rs similarity index 61% rename from src/search/hybrid.rs rename to crates/rust-memex/src/search/hybrid.rs index d5f63f6..d8661c9 100644 --- a/src/search/hybrid.rs +++ b/crates/rust-memex/src/search/hybrid.rs @@ -246,7 +246,7 @@ impl HybridSearcher { }) .collect(); Self::apply_post_search_processing(query, &mut results, &options); - Self::dedup_by_content_hash(&mut results); + Self::dedup_by_chunk_hash(&mut results); Self::dedup_by_source_path(&mut results); Self::enforce_source_diversity(&mut results, self.config.max_per_source); results.truncate(limit); @@ -267,6 +267,11 @@ impl HybridSearcher { .ok_or_else(|| anyhow::anyhow!("BM25 index not initialized for keyword search"))?; let bm25_results = bm25.search(query, namespace, candidate_limit(limit, &options))?; + if bm25_results.is_empty() { + return self + .keyword_scan_fallback(query, namespace, limit, options) + .await; + } // Fetch full documents from storage let mut results = Vec::with_capacity(bm25_results.len()); @@ -290,7 +295,77 @@ impl HybridSearcher { } Self::apply_post_search_processing(query, &mut results, &options); - Self::dedup_by_content_hash(&mut results); + Self::dedup_by_chunk_hash(&mut results); + Self::dedup_by_source_path(&mut results); + Self::enforce_source_diversity(&mut results, self.config.max_per_source); + results.truncate(limit); + Ok(results) + } + + async fn keyword_scan_fallback( + &self, + query: &str, + namespace: Option<&str>, + limit: usize, + options: SearchOptions, + ) -> Result> { + let terms = query + .split_whitespace() + .map(|term| { + term.trim_matches(|ch: char| !ch.is_alphanumeric()) + .to_ascii_lowercase() + }) + .filter(|term| !term.is_empty()) + .collect::>(); + if terms.is_empty() { + return Ok(vec![]); + } + + let docs = self.storage.all_documents(namespace, 100_000).await?; + let mut results = Vec::new(); + for doc in docs { + let searchable = format!( + "{} {} {}", + doc.document, + doc.keywords.join(" "), + doc.metadata + ) + .to_ascii_lowercase(); + let score = terms + .iter() + .map(|term| searchable.matches(term).count()) + .sum::(); + if score == 0 { + continue; + } + + let layer = doc.slice_layer(); + let layer_matches = match options.layer_filter { + Some(SliceLayer::Outer) => layer.is_none() || layer == Some(SliceLayer::Outer), + Some(expected) => layer == Some(expected), + None => true, + }; + if !layer_matches { + continue; + } + + results.push(HybridSearchResult { + id: doc.id, + namespace: doc.namespace, + document: doc.document, + combined_score: score as f32, + vector_score: None, + bm25_score: Some(0.0), + metadata: doc.metadata, + layer, + parent_id: doc.parent_id, + children_ids: doc.children_ids, + keywords: doc.keywords, + }); + } + + Self::apply_post_search_processing(query, &mut results, &options); + Self::dedup_by_chunk_hash(&mut results); Self::dedup_by_source_path(&mut results); Self::enforce_source_diversity(&mut results, self.config.max_per_source); results.truncate(limit); @@ -307,6 +382,7 @@ impl HybridSearcher { options: SearchOptions, ) -> Result> { let expanded_limit = candidate_limit(limit, &options); // Get more candidates for fusion + let query_policy = QueryPolicy::from_query(query); // Run vector search let vector_results = self @@ -382,8 +458,20 @@ impl HybridSearcher { } } + if query_policy.precision_query { + final_results.extend( + self.lexical_precision_candidates( + &query_policy, + namespace, + expanded_limit, + &options, + ) + .await?, + ); + } + Self::apply_post_search_processing(query, &mut final_results, &options); - Self::dedup_by_content_hash(&mut final_results); + Self::dedup_by_chunk_hash(&mut final_results); Self::dedup_by_source_path(&mut final_results); Self::enforce_source_diversity(&mut final_results, self.config.max_per_source); final_results.truncate(limit); @@ -398,6 +486,58 @@ impl HybridSearcher { Ok(final_results) } + async fn lexical_precision_candidates( + &self, + query_policy: &QueryPolicy, + namespace: Option<&str>, + limit: usize, + options: &SearchOptions, + ) -> Result> { + let docs = self.storage.all_documents(namespace, 100_000).await?; + let mut results = Vec::new(); + + for doc in docs { + let layer = doc.slice_layer(); + let layer_matches = match options.layer_filter { + Some(SliceLayer::Outer) => layer.is_none() || layer == Some(SliceLayer::Outer), + Some(expected) => layer == Some(expected), + None => true, + }; + if !layer_matches { + continue; + } + + let evidence = SearchEvidence::from_parts(&doc.document, &doc.keywords, &doc.metadata); + let score = query_policy.lexical_score(&evidence); + if score <= 0.0 { + continue; + } + + results.push(HybridSearchResult { + id: doc.id, + namespace: doc.namespace, + document: doc.document, + combined_score: score, + vector_score: None, + bm25_score: Some(score), + metadata: doc.metadata, + layer, + parent_id: doc.parent_id, + children_ids: doc.children_ids, + keywords: doc.keywords, + }); + } + + results.sort_by(|left, right| { + right + .combined_score + .partial_cmp(&left.combined_score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + results.truncate(limit); + Ok(results) + } + fn apply_post_search_processing( query: &str, results: &mut Vec, @@ -407,9 +547,32 @@ impl HybridSearcher { results.retain(|result| matches_project_filter(&result.metadata, project)); } + let query_policy = QueryPolicy::from_query(query); for result in results.iter_mut() { - result.combined_score = + let evidence = + SearchEvidence::from_parts(&result.document, &result.keywords, &result.metadata); + let lexical_score = query_policy.lexical_score(&evidence); + let mut score = boosted_score(query, result.combined_score, &result.metadata, result.layer); + + if query_policy.precision_query { + if lexical_score <= 0.0 { + score *= 0.12; + } else { + score += lexical_score; + } + + if result.layer == Some(SliceLayer::Outer) + && looks_like_keyword_stub(&result.document) + && lexical_score < query_policy.strong_match_threshold() + { + score *= 0.35; + } + } else if lexical_score > 0.0 { + score += lexical_score.min(0.25); + } + + result.combined_score = score; } results.sort_by(|left, right| { @@ -420,24 +583,36 @@ impl HybridSearcher { }); } - /// Deduplicate results by content_hash (chunk-level) from metadata. - /// content_hash = per-chunk hash (unique per chunk content). - /// file_hash = per-file hash (provenance, NOT used for dedup). - fn dedup_by_content_hash(results: &mut Vec) { + /// Deduplicate results by per-chunk hash from metadata. + /// + /// Schema v4 (post spec P0) stores the per-chunk SHA256 under + /// `metadata.chunk_hash`. The legacy field `metadata.content_hash` (v3 and + /// earlier) actually held the *source* document hash for every onion + /// layer, so reading it here would either no-op silently (post-v4 chunks + /// have no `content_hash` field in metadata) or aggressively collapse + /// every onion layer of a single source into one result (legacy chunks). + /// Either outcome contradicts the function name. + /// + /// Chunk-level dedup therefore keys strictly on `metadata.chunk_hash`. + /// Results without that field (legacy v3 chunks, no-hash flat indexers) + /// are passed through untouched — `dedup_by_source_path` and + /// `enforce_source_diversity` still bound the result list. + /// + /// `metadata.source_hash` is preserved for provenance and is not used + /// here; `metadata.file_hash` is a deprecated alias of `source_hash` and + /// is also ignored. + fn dedup_by_chunk_hash(results: &mut Vec) { let mut seen: HashSet = HashSet::new(); let before = results.len(); results.retain(|r| { - match r.metadata.get("content_hash").and_then(|v| v.as_str()) { + match r.metadata.get("chunk_hash").and_then(|v| v.as_str()) { Some(hash) => seen.insert(hash.to_string()), - None => true, // keep results without content_hash + None => true, // keep legacy/no-hash chunks; source-path dedup still applies } }); let removed = before - results.len(); if removed > 0 { - tracing::debug!( - "Dedup: removed {} duplicate chunks by content_hash", - removed - ); + tracing::debug!("Dedup: removed {} duplicate chunks by chunk_hash", removed); } } @@ -608,21 +783,208 @@ fn candidate_limit(limit: usize, options: &SearchOptions) -> usize { limit.max(1) * multiplier } +struct SearchEvidence { + text: String, +} + +impl SearchEvidence { + fn from_parts(document: &str, keywords: &[String], metadata: &Value) -> Self { + Self { + text: normalize_search_text(&format!( + "{} {} {}", + document, + keywords.join(" "), + metadata + )), + } + } + + fn contains(&self, needle: &str) -> bool { + self.text.contains(needle) + } +} + +#[derive(Debug, Clone)] +struct QueryPolicy { + precision_query: bool, + anchors: Vec>, +} + +impl QueryPolicy { + fn from_query(query: &str) -> Self { + let raw_terms = query + .split_whitespace() + .map(|term| term.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '-')) + .filter(|term| !term.is_empty()) + .collect::>(); + let normalized_terms = raw_terms + .iter() + .map(|term| normalize_search_text(term)) + .filter(|term| !term.is_empty() && !is_search_stopword(term)) + .collect::>(); + + let has_acronym = raw_terms.iter().any(|term| { + let alpha_count = term.chars().filter(|ch| ch.is_alphabetic()).count(); + let upper_count = term.chars().filter(|ch| ch.is_uppercase()).count(); + (2..=8).contains(&alpha_count) && upper_count >= 2 + }); + let has_definition_intent = normalized_terms.iter().any(|term| { + matches!( + term.as_str(), + "definition" | "defined" | "define" | "definicja" | "definicje" | "znaczenie" + ) + }); + let precision_query = normalized_terms.len() <= 4 && (has_acronym || has_definition_intent); + + let anchors = normalized_terms + .iter() + .map(|term| expanded_anchor_terms(term)) + .collect::>(); + + Self { + precision_query, + anchors, + } + } + + fn lexical_score(&self, evidence: &SearchEvidence) -> f32 { + if self.anchors.is_empty() { + return 0.0; + } + + let mut matched = 0usize; + let mut score = 0.0f32; + for anchor_group in &self.anchors { + let mut group_score = 0.0f32; + for anchor in anchor_group { + if evidence.contains(anchor) { + group_score = group_score.max(anchor_weight(anchor)); + } + } + if group_score > 0.0 { + matched += 1; + score += group_score; + } + } + + if self.precision_query && matched == self.anchors.len() { + score += 1.0; + } + + score + } + + fn strong_match_threshold(&self) -> f32 { + if self.anchors.len() >= 2 { 2.0 } else { 1.0 } + } +} + +fn expanded_anchor_terms(term: &str) -> Vec { + match term { + "dou" => vec![ + "dou".to_string(), + "vc dou".to_string(), + "vc-dou".to_string(), + "definition of undone".to_string(), + "undone".to_string(), + ], + "definicja" | "definicje" | "znaczenie" => vec![ + term.to_string(), + "definition".to_string(), + "defined".to_string(), + "define".to_string(), + ], + "definition" | "defined" | "define" => vec![ + term.to_string(), + "definicja".to_string(), + "definicje".to_string(), + ], + other => vec![other.to_string()], + } +} + +fn anchor_weight(anchor: &str) -> f32 { + match anchor { + "definition of undone" => 2.0, + "vc dou" | "vc-dou" | "dou" => 1.4, + "definition" | "definicja" | "definicje" => 1.2, + _ => 1.0, + } +} + +fn normalize_search_text(value: &str) -> String { + value + .to_lowercase() + .chars() + .map(|ch| { + if ch.is_alphanumeric() || ch.is_whitespace() || ch == '-' { + ch + } else { + ' ' + } + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn is_search_stopword(term: &str) -> bool { + matches!( + term, + "the" + | "a" + | "an" + | "and" + | "or" + | "of" + | "in" + | "o" + | "i" + | "w" + | "we" + | "na" + | "do" + | "to" + | "jest" + | "co" + | "czym" + ) +} + +fn looks_like_keyword_stub(document: &str) -> bool { + let trimmed = document.trim(); + trimmed.starts_with('[') + && trimmed + .lines() + .next() + .is_some_and(|line| line.len() < 180 && line.contains(',') && line.contains(']')) +} + fn matches_project_filter(metadata: &Value, project: &str) -> bool { let needle = project.trim(); if needle.is_empty() { return true; } + let needle = canonical_project_identity(needle); + metadata.as_object().is_some_and(|object| { ["project", "project_id", "source_project"] .iter() .filter_map(|key| object.get(*key)) .filter_map(|value| value.as_str()) - .any(|value| value.eq_ignore_ascii_case(needle)) + .any(|value| canonical_project_identity(value) == needle) }) } +fn canonical_project_identity(value: &str) -> String { + match value.trim().to_ascii_lowercase().as_str() { + "loctree" | "vetcoders" => "vetcoders".to_string(), + other => other.to_string(), + } +} + fn boosted_score(query: &str, base_score: f32, metadata: &Value, layer: Option) -> f32 { (base_score * layer_multiplier(query, layer)) + recency_bonus(metadata) } @@ -739,6 +1101,29 @@ mod tests { assert!(!config.use_rrf); } + #[test] + fn precision_query_boosts_dou_definition_evidence() { + let policy = QueryPolicy::from_query("DoU definicja"); + assert!(policy.precision_query); + + let good = SearchEvidence::from_parts( + "8. vc-dou — Definition of Undone audit calej powierzchni produktu", + &[], + &json!({}), + ); + let weak = SearchEvidence::from_parts( + "[assistant, nie, search, wiec, juz] [project: VetCoders/ai-contexters]", + &[], + &json!({}), + ); + + assert!(policy.lexical_score(&good) >= policy.strong_match_threshold()); + assert_eq!(policy.lexical_score(&weak), 0.0); + assert!(looks_like_keyword_stub( + "[assistant, nie, search, wiec, juz] [project: x]" + )); + } + #[tokio::test] async fn test_keyword_search_uses_bm25_hit_namespace() { let storage_dir = TempDir::new().unwrap(); @@ -796,6 +1181,51 @@ mod tests { ); } + #[tokio::test] + async fn keyword_search_falls_back_to_lance_text_when_bm25_is_empty() { + let storage_dir = TempDir::new().unwrap(); + let bm25_dir = TempDir::new().unwrap(); + let storage = Arc::new( + StorageManager::new_lance_only(storage_dir.path().to_str().unwrap()) + .await + .unwrap(), + ); + + storage + .add_to_store(vec![ChromaDocument::new_flat( + "doc-1".to_string(), + "namespace-a".to_string(), + vec![0.1f32; 8], + json!({"path": "fallback.txt"}), + "needle term only exists in Lance".to_string(), + )]) + .await + .unwrap(); + + let config = HybridConfig { + mode: SearchMode::Keyword, + bm25: BM25Config::default().with_path(bm25_dir.path().to_str().unwrap()), + max_per_source: 0, + ..Default::default() + }; + let searcher = HybridSearcher::new(storage, config).await.unwrap(); + + let results = searcher + .search( + "needle", + vec![], + Some("namespace-a"), + 10, + SearchOptions::default(), + ) + .await + .unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "doc-1"); + assert_eq!(results[0].namespace, "namespace-a"); + } + #[tokio::test] async fn test_hybrid_search_keeps_duplicate_ids_across_namespaces() { let storage_dir = TempDir::new().unwrap(); @@ -904,6 +1334,123 @@ mod tests { assert_eq!(results[1].id, "doc-other"); } + /// Schema v4 (post spec P0) writes per-chunk SHA256 under + /// `metadata.chunk_hash`. The pre-v4 dedup keyed off `metadata.content_hash`, + /// which is either absent (v4) or holds the source-document hash (v3 + /// legacy) — neither matches the per-chunk semantic the function name + /// promises. This test locks the v4 contract: identical `chunk_hash` + /// collapses to one survivor, distinct hashes survive, missing field is + /// passed through. + #[test] + fn dedup_by_chunk_hash_collapses_v4_chunk_duplicates_only() { + fn fixture(id: &str, score: f32, chunk_hash: Option<&str>) -> HybridSearchResult { + let metadata = match chunk_hash { + Some(hash) => json!({ + "path": "/tmp/transcripts/a.md", + "source_hash": "src-aaaa", + "chunk_hash": hash, + "layer": "outer", + }), + None => json!({ + "path": "/tmp/transcripts/legacy.md", + "source_hash": "src-aaaa", + "layer": "outer", + }), + }; + HybridSearchResult { + id: id.to_string(), + namespace: "kb:transcripts".to_string(), + document: format!("{id} document text"), + combined_score: score, + vector_score: Some(score), + bm25_score: Some(score), + metadata, + layer: None, + parent_id: None, + children_ids: vec![], + keywords: vec![], + } + } + + // Two identical-chunk hits arrive in score order (e.g. same chunk + // surfaced through both vector and BM25 lanes); only the first must + // survive. A distinct chunk and a legacy chunk (no `chunk_hash`) must + // pass through untouched. + let mut results = vec![ + fixture("dup-high", 0.9, Some("chunk-aaaa")), + fixture("dup-low", 0.7, Some("chunk-aaaa")), + fixture("unique", 0.6, Some("chunk-bbbb")), + fixture("legacy", 0.5, None), + ]; + + HybridSearcher::dedup_by_chunk_hash(&mut results); + + assert_eq!( + results.len(), + 3, + "expected duplicate chunk_hash to collapse, got {:?}", + results.iter().map(|r| &r.id).collect::>() + ); + assert_eq!(results[0].id, "dup-high", "first duplicate must win"); + assert!( + results.iter().any(|r| r.id == "unique"), + "distinct chunk_hash must survive" + ); + assert!( + results.iter().any(|r| r.id == "legacy"), + "legacy chunk without chunk_hash must pass through" + ); + assert!( + !results.iter().any(|r| r.id == "dup-low"), + "second duplicate must be removed" + ); + } + + /// Pre-v4 metadata stored the *source* document hash under + /// `content_hash`, so every onion layer of a single source shared the + /// same value. The post-P0 dedup must NOT key on that legacy field — + /// otherwise outer/middle/inner/core hits collapse to a single layer at + /// search time, masking the onion structure operators rebuilt the + /// namespace to expose. This test pins that boundary. + #[test] + fn dedup_by_chunk_hash_ignores_legacy_content_hash_field() { + fn legacy(id: &str, layer: &str) -> HybridSearchResult { + HybridSearchResult { + id: id.to_string(), + namespace: "kb:transcripts".to_string(), + document: format!("{id} {layer}"), + combined_score: 0.5, + vector_score: Some(0.5), + bm25_score: Some(0.4), + metadata: json!({ + "path": "/tmp/transcripts/legacy.md", + // Pre-v4 quirk: every layer carried source_hash here. + "content_hash": "shared-source-hash", + "layer": layer, + }), + layer: None, + parent_id: None, + children_ids: vec![], + keywords: vec![], + } + } + + let mut results = vec![ + legacy("outer", "outer"), + legacy("middle", "middle"), + legacy("inner", "inner"), + legacy("core", "core"), + ]; + + HybridSearcher::dedup_by_chunk_hash(&mut results); + + assert_eq!( + results.len(), + 4, + "legacy `content_hash` (== source hash) must not collapse onion layers" + ); + } + #[test] fn project_filter_matches_project_and_project_id() { assert!(matches_project_filter( diff --git a/src/search/mod.rs b/crates/rust-memex/src/search/mod.rs similarity index 100% rename from src/search/mod.rs rename to crates/rust-memex/src/search/mod.rs diff --git a/crates/rust-memex/src/security/mod.rs b/crates/rust-memex/src/security/mod.rs new file mode 100644 index 0000000..683bdee --- /dev/null +++ b/crates/rust-memex/src/security/mod.rs @@ -0,0 +1,38 @@ +//! Namespace security configuration. +//! +//! Historically this module hosted `NamespaceAccessManager` + `TokenStore`: a +//! single-token-per-namespace auth layer with a bespoke on-disk schema. That +//! implementation was superseded by [`crate::auth::AuthManager`], which +//! provides per-token scopes, namespace ACLs, argon2id-hashed storage, and +//! rotation. +//! +//! What remains here is the runtime-configuration struct consumed by +//! [`crate::ServerConfig`]. The rest of the legacy surface was deleted along +//! with the Track C migration (v0.6.0). If you are looking for "how do I +//! check access to a namespace?" — go to `crate::auth::AuthManager`. +//! +//! Vibecrafted with AI Agents by Loctree (c)2024-2026 The LibraxisAI Team + +use serde::{Deserialize, Serialize}; + +/// Configuration for namespace security (token-based access control). +/// +/// Preserved as a config DTO so CLI/file-config surfaces keep working. The +/// actual enforcement now lives in [`crate::auth::AuthManager`]; this struct +/// only tells the runtime *whether* to spin up an auth manager and where the +/// token store lives on disk. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct NamespaceSecurityConfig { + /// Whether token-based access control is enabled. + /// + /// When `false`, the runtime wires up an `AuthManager` with an empty + /// store and no legacy token — every request is allowed. + #[serde(default)] + pub enabled: bool, + /// Path to the token store file (`tokens.json`, v2 schema). + /// + /// Defaults to `~/.rmcp-servers/rust-memex/tokens.json` when `enabled` + /// is set but no path is configured. + #[serde(default)] + pub token_store_path: Option, +} diff --git a/crates/rust-memex/src/startup.rs b/crates/rust-memex/src/startup.rs new file mode 100644 index 0000000..4c65d92 --- /dev/null +++ b/crates/rust-memex/src/startup.rs @@ -0,0 +1,43 @@ +use anyhow::{Result, anyhow}; +use tracing::info; + +use crate::{SchemaMigrationReport, SchemaMismatchWriteError, SchemaVersion, StorageManager}; + +#[derive(Debug, Clone)] +pub enum StartupSchemaGuard { + UpToDate, + Migrated(SchemaMigrationReport), +} + +pub async fn guard_daemon_startup_schema( + db_path: &str, + auto_migrate: bool, +) -> Result { + let storage = StorageManager::new_lance_only(db_path).await?; + + match storage.require_current_schema_for_writes().await { + Ok(()) => Ok(StartupSchemaGuard::UpToDate), + Err(error) => { + let Some(schema_error) = error.downcast_ref::() else { + return Err(error); + }; + let db_path = schema_error.db_path().to_string(); + + if !auto_migrate { + return Err(anyhow!( + "ERROR: binary requires schema v4, table is v3-pre.\n\ + Run 'rust-memex migrate-schema --db-path {db_path}' before starting daemon.\n\ + Or pass --auto-migrate to migrate at startup." + )); + } + + let message = "migrating schema v3-pre -> v4 at startup"; + info!("{message}"); + eprintln!("INFO: {message}"); + let report = + StorageManager::migrate_lance_schema(&db_path, SchemaVersion::current(), false) + .await?; + Ok(StartupSchemaGuard::Migrated(report)) + } + } +} diff --git a/src/storage/mod.rs b/crates/rust-memex/src/storage/mod.rs similarity index 50% rename from src/storage/mod.rs rename to crates/rust-memex/src/storage/mod.rs index f4a5a8c..1a8da47 100644 --- a/src/storage/mod.rs +++ b/crates/rust-memex/src/storage/mod.rs @@ -1,21 +1,25 @@ use anyhow::{Result, anyhow}; use arrow_array::types::Float32Type; use arrow_array::{ - Array, FixedSizeListArray, Float32Array, RecordBatch, RecordBatchIterator, StringArray, - UInt8Array, + Array, FixedSizeListArray, Float32Array, RecordBatch, RecordBatchIterator, RecordBatchReader, + StringArray, UInt8Array, }; -use arrow_schema::{ArrowError, DataType, Field, Schema}; +use arrow_schema::{DataType, Field, Schema}; use futures::TryStreamExt; use lancedb::connection::Connection; use lancedb::query::{ExecutableQuery, QueryBase}; -use lancedb::table::{OptimizeAction, OptimizeStats}; +use lancedb::table::{NewColumnTransform, OptimizeAction, OptimizeStats}; use lancedb::{Table, connect}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fmt; use std::path::{Path, PathBuf}; +use std::str::FromStr; use std::sync::Arc; use tokio::sync::Mutex; -use tracing::{debug, info}; +use tracing::{debug, error, info, warn}; use uuid::Uuid; use crate::rag::SliceLayer; @@ -23,8 +27,182 @@ use crate::rag::SliceLayer; /// Schema version for LanceDB tables. Increment when changing table structure. /// Version 2: Added onion slice fields (layer, parent_id, children_ids, keywords) /// Version 3: Added content_hash for exact-match deduplication +/// Version 4: Split `content_hash` into per-chunk SHA256 (this column) and a +/// new `source_hash` column for the SHA256 of the source document +/// text. This enables both layer-aware chunk dedup and pre-index +/// source-level dedup. Pre-v4 rows store source-text hash in +/// `content_hash`; backfill via `/admin/backfill-hashes` corrects +/// this without breaking old data. /// See docs/MIGRATION.md for migration procedures. -pub const SCHEMA_VERSION: u32 = 3; +pub const SCHEMA_VERSION: u32 = 4; +pub const DEFAULT_TABLE_NAME: &str = "mcp_documents"; +const NAMESPACE_TABLE_PREFIX: &str = "mcp_documents__ns__"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SchemaVersion { + V3, + V4, +} + +impl SchemaVersion { + pub fn current() -> Self { + Self::V4 + } +} + +impl fmt::Display for SchemaVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::V3 => f.write_str("v3"), + Self::V4 => f.write_str("v4"), + } + } +} + +impl FromStr for SchemaVersion { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "3" | "v3" => Ok(Self::V3), + "4" | "v4" => Ok(Self::V4), + other => Err(anyhow!( + "unsupported schema target '{other}' (expected v3 or v4)" + )), + } + } +} + +#[derive(Debug, Clone)] +pub struct SchemaMigrationReport { + pub target: SchemaVersion, + pub missing_columns: Vec, + pub applied: bool, +} + +impl SchemaMigrationReport { + pub fn missing_column_names(&self) -> Vec<&str> { + self.missing_columns + .iter() + .map(|field| field.name().as_str()) + .collect() + } +} + +#[derive(Debug, Clone)] +pub struct SchemaStatusReport { + pub schema_version: SchemaVersion, + pub expected_schema: SchemaVersion, + pub needs_migration: bool, + pub missing_columns: Vec, + pub manifest_version: Option, +} + +pub fn required_columns_for(version: SchemaVersion) -> Vec { + let mut fields = vec![Field::new("content_hash", DataType::Utf8, true)]; + if matches!(version, SchemaVersion::V4) { + fields.push(Field::new("source_hash", DataType::Utf8, true)); + } + fields +} + +#[derive(Debug, Clone)] +pub struct SchemaMismatchWriteError { + table_name: String, + db_path: String, + missing_columns: Vec, + message: String, +} + +impl SchemaMismatchWriteError { + fn new( + table_name: impl Into, + db_path: impl Into, + missing_columns: Vec, + message: impl Into, + ) -> Self { + Self { + table_name: table_name.into(), + db_path: db_path.into(), + missing_columns, + message: message.into(), + } + } + + pub fn missing_columns(&self) -> &[String] { + &self.missing_columns + } + + pub fn db_path(&self) -> &str { + &self.db_path + } + + pub fn table_name(&self) -> &str { + &self.table_name + } + + pub fn remediation(&self) -> String { + format!("rust-memex migrate-schema --db-path {}", self.db_path) + } +} + +impl fmt::Display for SchemaMismatchWriteError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "ERROR schema mismatch while writing Lance table '{}': missing columns {:?}. {}. Remediation: {}", + self.table_name, + self.missing_columns, + self.message, + self.remediation() + ) + } +} + +impl std::error::Error for SchemaMismatchWriteError {} + +fn is_schema_mismatch_message(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("schema mismatch") + || lower.contains("append with different schema") + || lower.contains("fields did not match") + || lower.contains("missing=[") +} + +fn extract_missing_columns(message: &str) -> Vec { + let mut columns = Vec::new(); + let mut rest = message; + + while let Some(start) = rest.find("missing=[") { + rest = &rest[start + "missing=[".len()..]; + let Some(end) = rest.find(']') else { + break; + }; + let list = &rest[..end]; + for item in list.split(',') { + let column = item + .trim() + .trim_matches('`') + .trim_matches('"') + .trim_matches('\''); + if !column.is_empty() && !columns.iter().any(|existing| existing == column) { + columns.push(column.to_string()); + } + } + rest = &rest[end + 1..]; + } + + if columns.is_empty() { + for field in required_columns_for(SchemaVersion::current()) { + let name = field.name(); + if message.contains(name) { + columns.push(name.to_string()); + } + } + } + + columns +} // ============================================================================= // STORAGE BACKEND INTERFACE @@ -61,8 +239,15 @@ pub struct ChromaDocument { pub children_ids: Vec, /// Extracted keywords for this slice pub keywords: Vec, - /// SHA256 hash of original content for exact-match deduplication + /// SHA256 hash of THIS chunk's text. Used for chunk-level deduplication. + /// Pre-v4 rows may transitionally store the source-text hash here; the + /// `/admin/backfill-hashes` endpoint corrects them. pub content_hash: Option, + /// SHA256 hash of the SOURCE document text (same value across all four + /// onion layers from one source). Used for pre-index dedup so we never + /// re-embed an already-ingested file. Optional for backward compatibility + /// with v3 schemas — `None` means "unknown source provenance". + pub source_hash: Option, } impl ChromaDocument { @@ -85,10 +270,13 @@ impl ChromaDocument { children_ids: vec![], keywords: vec![], content_hash: None, + source_hash: None, } } - /// Create a new document with content hash for deduplication + /// Create a new document with content hash for deduplication. + /// Source hash is left empty — prefer `new_flat_with_hashes` so callers + /// supply the source-document hash explicitly when they have it. pub fn new_flat_with_hash( id: String, namespace: String, @@ -96,6 +284,27 @@ impl ChromaDocument { metadata: serde_json::Value, document: String, content_hash: String, + ) -> Self { + Self::new_flat_with_hashes( + id, + namespace, + embedding, + metadata, + document, + content_hash, + None, + ) + } + + /// Create a flat (legacy) document with both per-chunk and source hashes. + pub fn new_flat_with_hashes( + id: String, + namespace: String, + embedding: Vec, + metadata: serde_json::Value, + document: String, + content_hash: String, + source_hash: Option, ) -> Self { Self { id, @@ -108,6 +317,7 @@ impl ChromaDocument { children_ids: vec![], keywords: vec![], content_hash: Some(content_hash), + source_hash, } } @@ -129,16 +339,38 @@ impl ChromaDocument { children_ids: slice.children_ids.clone(), keywords: slice.keywords.clone(), content_hash: None, + source_hash: None, } } - /// Create a document from an onion slice with content hash for deduplication + /// Create a document from an onion slice with content hash for deduplication. + /// Source hash is left empty — prefer `from_onion_slice_with_hashes` to + /// preserve source provenance for pre-index dedup. pub fn from_onion_slice_with_hash( slice: &crate::rag::OnionSlice, namespace: String, embedding: Vec, metadata: serde_json::Value, content_hash: String, + ) -> Self { + Self::from_onion_slice_with_hashes( + slice, + namespace, + embedding, + metadata, + content_hash, + None, + ) + } + + /// Create an onion-slice document with both per-chunk and source hashes. + pub fn from_onion_slice_with_hashes( + slice: &crate::rag::OnionSlice, + namespace: String, + embedding: Vec, + metadata: serde_json::Value, + content_hash: String, + source_hash: Option, ) -> Self { Self { id: slice.id.clone(), @@ -151,6 +383,7 @@ impl ChromaDocument { children_ids: slice.children_ids.clone(), keywords: slice.keywords.clone(), content_hash: Some(content_hash), + source_hash, } } @@ -168,6 +401,7 @@ impl ChromaDocument { pub struct StorageManager { lance: Connection, table: Arc>>, + namespace_tables: Arc>>, collection_name: String, lance_path: String, } @@ -215,8 +449,11 @@ impl CrossStoreRecoveryBatch { } } -type BatchIter = - RecordBatchIterator>>; +// lancedb 0.27 `Table::add` requires `T: Scannable`. `RecordBatchIterator` +// impls `RecordBatchReader`, and `Box` +// has a blanket `Scannable` impl — boxing satisfies the bound without changing +// the producer side. +type BatchIter = Box; impl StorageManager { pub async fn new(db_path: &str) -> Result { @@ -233,7 +470,8 @@ impl StorageManager { Ok(Self { lance, table: Arc::new(Mutex::new(None)), - collection_name: "mcp_documents".to_string(), + namespace_tables: Arc::new(Mutex::new(HashMap::new())), + collection_name: DEFAULT_TABLE_NAME.to_string(), lance_path, }) } @@ -247,7 +485,8 @@ impl StorageManager { Ok(Self { lance, table: Arc::new(Mutex::new(None)), - collection_name: "mcp_documents".to_string(), + namespace_tables: Arc::new(Mutex::new(HashMap::new())), + collection_name: DEFAULT_TABLE_NAME.to_string(), lance_path, }) } @@ -256,6 +495,109 @@ impl StorageManager { &self.lance_path } + pub async fn require_current_schema_for_writes(&self) -> Result<()> { + let Some(table) = self.open_table_if_exists().await? else { + return Ok(()); + }; + self.ensure_hash_schema_columns(&table).await + } + + pub async fn schema_status( + &self, + expected_schema: SchemaVersion, + ) -> Result { + let Some(table) = self.open_table_if_exists().await? else { + return Ok(SchemaStatusReport { + schema_version: expected_schema, + expected_schema, + needs_migration: false, + missing_columns: Vec::new(), + manifest_version: None, + }); + }; + + let missing_columns = Self::missing_required_columns(&table, expected_schema) + .await? + .into_iter() + .map(|field| field.name().to_string()) + .collect::>(); + let manifest_version = table + .list_versions() + .await + .ok() + .and_then(|versions| versions.iter().map(|version| version.version).max()); + + Ok(SchemaStatusReport { + schema_version: if missing_columns.is_empty() { + expected_schema + } else { + SchemaVersion::V3 + }, + expected_schema, + needs_migration: !missing_columns.is_empty(), + missing_columns, + manifest_version, + }) + } + + pub async fn missing_required_columns( + table: &Table, + target: SchemaVersion, + ) -> Result> { + let schema = table.schema().await?; + Ok(required_columns_for(target) + .into_iter() + .filter(|field| schema.field_with_name(field.name()).is_err()) + .collect()) + } + + pub async fn migrate_lance_schema( + db_path: &str, + target: SchemaVersion, + check_only: bool, + ) -> Result { + let lance_path = shellexpand::tilde(db_path).to_string(); + let lance = connect(&lance_path).execute().await?; + let table = lance.open_table(DEFAULT_TABLE_NAME).execute().await?; + let missing = Self::missing_required_columns(&table, target).await?; + + if missing.is_empty() || check_only { + return Ok(SchemaMigrationReport { + target, + missing_columns: missing, + applied: false, + }); + } + + let transform = NewColumnTransform::AllNulls(Arc::new(Schema::new(missing.clone()))); + if let Err(error) = table.add_columns(transform, None).await { + let _ = table.checkout_latest().await; + let remaining = Self::missing_required_columns(&table, target).await?; + if remaining.is_empty() { + warn!( + "Lance table '{}' schema migration raced with another writer and is already complete", + DEFAULT_TABLE_NAME + ); + return Ok(SchemaMigrationReport { + target, + missing_columns: missing, + applied: true, + }); + } + return Err(anyhow!( + "failed to migrate Lance table '{}' schema to {target}: {error}", + DEFAULT_TABLE_NAME + )); + } + + let _ = table.checkout_latest().await; + Ok(SchemaMigrationReport { + target, + missing_columns: missing, + applied: true, + }) + } + pub fn cross_store_recovery_dir(&self) -> PathBuf { let db_path = Path::new(&self.lance_path); let parent = db_path.parent().unwrap_or_else(|| Path::new(".")); @@ -336,6 +678,7 @@ impl StorageManager { pub async fn refresh(&self) -> Result<()> { let mut guard = self.table.lock().await; *guard = None; + self.namespace_tables.lock().await.clear(); tracing::info!("LanceDB table cache cleared - will refresh on next query"); Ok(()) } @@ -415,13 +758,25 @@ impl StorageManager { } } - let table = self.ensure_table(dim).await?; - let batch = self.docs_to_batch(&documents, dim)?; - table.add(batch).execute().await?; - debug!( - "Inserted {} documents into Lance (validated)", - documents.len() - ); + let mut by_namespace: HashMap> = HashMap::new(); + for document in documents { + by_namespace + .entry(document.namespace.clone()) + .or_default() + .push(document); + } + + let mut inserted = 0usize; + for (namespace, docs) in by_namespace { + let table = self.ensure_namespace_table(&namespace, dim).await?; + self.ensure_hash_schema_columns(&table).await?; + let batch = self.docs_to_batch(&docs, dim)?; + if let Err(error) = table.add(batch).execute().await { + return Err(self.map_lancedb_write_error(error)); + } + inserted += docs.len(); + } + debug!("Inserted {} documents into Lance (validated)", inserted); Ok(()) } @@ -434,19 +789,51 @@ impl StorageManager { if embedding.is_empty() { return Ok(vec![]); } - let dim = embedding.len(); - let table = self.ensure_table(dim).await?; - - let mut query = table.query(); - if let Some(ns) = namespace { - query = query.only_if(self.namespace_filter(ns).as_str()); - } - let mut stream = query.nearest_to(embedding)?.limit(k).execute().await?; - let mut results = Vec::new(); - while let Some(batch) = stream.try_next().await? { - let mut docs = self.batch_to_docs(&batch)?; - results.append(&mut docs); + if let Some(ns) = namespace { + if let Some(table) = self.open_namespace_table_if_exists(ns).await? { + let mut stream = table + .query() + .nearest_to(embedding.clone())? + .limit(k) + .execute() + .await?; + while let Some(batch) = stream.try_next().await? { + let mut docs = self.batch_to_docs(&batch)?; + results.append(&mut docs); + } + } + if let Some(table) = self.legacy_table_if_exists().await? { + let mut stream = table + .query() + .only_if(self.namespace_filter(ns).as_str()) + .nearest_to(embedding)? + .limit(k) + .execute() + .await?; + while let Some(batch) = stream.try_next().await? { + let mut docs = self.batch_to_docs(&batch)?; + results.append(&mut docs); + } + } + results.truncate(k); + } else { + for table_name in self.data_table_names().await? { + let Some(table) = self.open_named_table_if_exists(&table_name).await? else { + continue; + }; + let mut stream = table + .query() + .nearest_to(embedding.clone())? + .limit(k) + .execute() + .await?; + while let Some(batch) = stream.try_next().await? { + let mut docs = self.batch_to_docs(&batch)?; + results.append(&mut docs); + } + } + results.truncate(k); } debug!("Lance returned {} results", results.len()); Ok(results) @@ -463,24 +850,41 @@ impl StorageManager { offset: usize, limit: usize, ) -> Result> { - let table = match self.open_table_if_exists().await? { - Some(t) => t, - None => return Ok(vec![]), - }; - - let mut query = table.query().limit(limit).offset(offset); - if let Some(ns) = namespace { - query = query.only_if(self.namespace_filter(ns).as_str()); - } - let mut stream = query.execute().await?; - let mut results = Vec::new(); - while let Some(batch) = stream.try_next().await? { - let mut docs = self.batch_to_docs(&batch)?; - results.append(&mut docs); + if let Some(ns) = namespace { + if let Some(table) = self.open_namespace_table_if_exists(ns).await? { + results.append( + &mut self + .query_table_page(&table, None, 0, offset + limit) + .await?, + ); + } + if let Some(table) = self.legacy_table_if_exists().await? { + results.append( + &mut self + .query_table_page( + &table, + Some(self.namespace_filter(ns)), + 0, + offset + limit, + ) + .await?, + ); + } + } else { + for table_name in self.data_table_names().await? { + let Some(table) = self.open_named_table_if_exists(&table_name).await? else { + continue; + }; + results.append( + &mut self + .query_table_page(&table, None, 0, offset + limit) + .await?, + ); + } } - Ok(results) + Ok(results.into_iter().skip(offset).take(limit).collect()) } /// Return documents without running a vector search. @@ -495,120 +899,284 @@ impl StorageManager { } pub async fn get_document(&self, namespace: &str, id: &str) -> Result> { - let table = match self.open_table_if_exists().await? { - Some(t) => t, - None => return Ok(None), - }; - let filter = format!( - "{} AND {}", - self.namespace_filter(namespace), - self.id_filter(id) - ); - let mut stream = table - .query() - .only_if(filter.as_str()) - .limit(1) - .execute() - .await?; - if let Some(batch) = stream.try_next().await? { - let mut docs = self.batch_to_docs(&batch)?; - if let Some(doc) = docs.pop() { - return Ok(Some(doc)); + let id_filter = self.id_filter(id); + if let Some(table) = self.open_namespace_table_if_exists(namespace).await? { + let mut stream = table + .query() + .only_if(id_filter.as_str()) + .limit(1) + .execute() + .await?; + if let Some(batch) = stream.try_next().await? { + let mut docs = self.batch_to_docs(&batch)?; + if let Some(doc) = docs.pop() { + return Ok(Some(doc)); + } + } + } + + if let Some(table) = self.legacy_table_if_exists().await? { + let filter = format!("{} AND {}", self.namespace_filter(namespace), id_filter); + let mut stream = table + .query() + .only_if(filter.as_str()) + .limit(1) + .execute() + .await?; + if let Some(batch) = stream.try_next().await? { + let mut docs = self.batch_to_docs(&batch)?; + if let Some(doc) = docs.pop() { + return Ok(Some(doc)); + } } } Ok(None) } pub async fn delete_document(&self, namespace: &str, id: &str) -> Result { - let table = match self.open_table_if_exists().await? { - Some(t) => t, - None => return Ok(0), - }; - let predicate = format!( - "{} AND {}", - self.namespace_filter(namespace), - self.id_filter(id) - ); - let pre_count = table - .query() - .only_if(predicate.as_str()) - .execute() - .await? - .try_fold( - 0usize, - |acc, batch| async move { Ok(acc + batch.num_rows()) }, - ) - .await?; - if pre_count == 0 { + let mut deleted = 0usize; + let id_filter = self.id_filter(id); + + if let Some(table) = self.open_namespace_table_if_exists(namespace).await? { + let pre_count = table.count_rows(Some(id_filter.clone())).await?; + if pre_count > 0 { + table.delete(id_filter.as_str()).await?; + deleted += pre_count; + } + } + + if let Some(table) = self.legacy_table_if_exists().await? { + let predicate = format!("{} AND {}", self.namespace_filter(namespace), id_filter); + let pre_count = table.count_rows(Some(predicate.clone())).await?; + if pre_count > 0 { + table.delete(predicate.as_str()).await?; + deleted += pre_count; + } + } + Ok(deleted) + } + + /// Batch delete documents by IDs within a namespace. + /// + /// Issues a single `DELETE WHERE namespace = X AND id IN (...)` per chunk, + /// avoiding the per-document table scan that `delete_document` incurs when + /// called in a loop. Predicate is split into 500-id chunks to keep SQL + /// length bounded regardless of caller batch size. + pub async fn delete_documents(&self, namespace: &str, ids: &[&str]) -> Result { + if ids.is_empty() { return Ok(0); } - table.delete(predicate.as_str()).await?; - Ok(pre_count) + const CHUNK: usize = 500; + let mut total_deleted = 0usize; + for batch in ids.chunks(CHUNK) { + let id_list = batch + .iter() + .map(|id| format!("'{}'", id.replace('\'', "''"))) + .collect::>() + .join(", "); + let id_predicate = format!("id IN ({})", id_list); + if let Some(table) = self.open_namespace_table_if_exists(namespace).await? { + let pre_count = table.count_rows(Some(id_predicate.clone())).await?; + if pre_count > 0 { + table.delete(id_predicate.as_str()).await?; + total_deleted += pre_count; + } + } + if let Some(table) = self.legacy_table_if_exists().await? { + let predicate = + format!("{} AND {}", self.namespace_filter(namespace), id_predicate); + let pre_count = table.count_rows(Some(predicate.clone())).await?; + if pre_count > 0 { + table.delete(predicate.as_str()).await?; + total_deleted += pre_count; + } + } + } + Ok(total_deleted) } pub async fn delete_namespace_documents(&self, namespace: &str) -> Result { - let table = match self.open_table_if_exists().await? { - Some(t) => t, - None => return Ok(0), - }; - let predicate = self.namespace_filter(namespace); - let pre_count = table - .query() - .only_if(predicate.as_str()) - .execute() - .await? - .try_fold( - 0usize, - |acc, batch| async move { Ok(acc + batch.num_rows()) }, - ) - .await?; - if pre_count == 0 { + let mut deleted = 0usize; + if let Some(table) = self.open_namespace_table_if_exists(namespace).await? { + let pre_count = table.count_rows(None).await?; + if pre_count > 0 { + table + .delete(self.namespace_filter(namespace).as_str()) + .await?; + deleted += pre_count; + } + } + if let Some(table) = self.legacy_table_if_exists().await? { + let predicate = self.namespace_filter(namespace); + let pre_count = table.count_rows(Some(predicate.clone())).await?; + if pre_count > 0 { + table.delete(predicate.as_str()).await?; + deleted += pre_count; + } + } + Ok(deleted) + } + + pub async fn rename_namespace_atomic(&self, from: &str, to: &str) -> Result { + if from == to { + return Ok(0); + } + + let source_count = self.count_namespace(from).await?; + if source_count == 0 { return Ok(0); } - table.delete(predicate.as_str()).await?; - Ok(pre_count) + + let target_count = self.count_namespace(to).await?; + if target_count > 0 { + return Err(anyhow!( + "Target namespace '{}' already exists with {} rows", + to, + target_count + )); + } + + let mut docs = self.all_documents(Some(from), source_count).await?; + for doc in &mut docs { + doc.namespace = to.to_string(); + } + self.add_to_store(docs).await?; + let deleted = self.delete_namespace_documents(from).await?; + + Ok(deleted) } pub fn get_collection_name(&self) -> &str { &self.collection_name } - async fn ensure_table(&self, dim: usize) -> Result { - let mut guard = self.table.lock().await; - if let Some(table) = guard.as_ref() { - return Ok(table.clone()); + fn namespace_table_name(namespace: &str) -> String { + let mut safe = namespace + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '_' + } + }) + .collect::(); + while safe.contains("__") { + safe = safe.replace("__", "_"); } + let safe = safe.trim_matches('_'); + let safe = if safe.is_empty() { "default" } else { safe }; + let safe = safe.chars().take(48).collect::(); + let hash = Sha256::digest(namespace.as_bytes()); + let suffix = hash[..6] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("{NAMESPACE_TABLE_PREFIX}{safe}_{suffix}") + } - let maybe_table = self - .lance - .open_table(self.collection_name.as_str()) - .execute() - .await; + fn is_namespace_table_name(table_name: &str) -> bool { + table_name.starts_with(NAMESPACE_TABLE_PREFIX) + } - let table = if let Ok(tbl) = maybe_table { - tbl - } else { - if dim == 0 { - return Err(anyhow!( - "Vector table '{}' not found and dimension is unknown", - self.collection_name - )); + async fn data_table_names(&self) -> Result> { + let table_names = self.lance.table_names().execute().await?; + Ok(table_names + .into_iter() + .filter(|name| name == DEFAULT_TABLE_NAME || Self::is_namespace_table_name(name)) + .collect()) + } + + async fn open_named_table_if_exists(&self, table_name: &str) -> Result> { + match self.lance.open_table(table_name).execute().await { + Ok(table) => Ok(Some(table)), + Err(e) => { + let msg = e.to_string().to_lowercase(); + if msg.contains("not found") + || msg.contains("does not exist") + || msg.contains("no such file") + { + Ok(None) + } else { + Err(anyhow!("LanceDB error on table '{}': {}", table_name, e)) + } + } + } + } + + async fn open_namespace_table_if_exists(&self, namespace: &str) -> Result> { + let table_name = Self::namespace_table_name(namespace); + if let Some(table) = self.namespace_tables.lock().await.get(&table_name).cloned() { + return Ok(Some(table)); + } + let table = self.open_named_table_if_exists(&table_name).await?; + if let Some(table) = &table { + self.namespace_tables + .lock() + .await + .insert(table_name, table.clone()); + } + Ok(table) + } + + async fn ensure_namespace_table(&self, namespace: &str, dim: usize) -> Result
{ + let table_name = Self::namespace_table_name(namespace); + if let Some(table) = self.namespace_tables.lock().await.get(&table_name).cloned() { + return Ok(table); + } + + let table = match self.open_named_table_if_exists(&table_name).await? { + Some(table) => table, + None => { + if dim == 0 { + return Err(anyhow!( + "Vector table '{}' not found and dimension is unknown", + table_name + )); + } + info!( + "Creating Lance namespace table '{}' for '{}' with vector dimension {} (schema v{})", + table_name, namespace, dim, SCHEMA_VERSION + ); + let schema = Arc::new(Self::create_schema(dim)); + self.lance + .create_empty_table(table_name.as_str(), schema) + .execute() + .await? } - info!( - "Creating Lance table '{}' with vector dimension {} (schema v{})", - self.collection_name, dim, SCHEMA_VERSION - ); - let schema = Arc::new(Self::create_schema(dim)); - self.lance - .create_empty_table(self.collection_name.as_str(), schema) - .execute() - .await? }; - *guard = Some(table.clone()); + self.namespace_tables + .lock() + .await + .insert(table_name, table.clone()); Ok(table) } + async fn query_table_page( + &self, + table: &Table, + filter: Option, + offset: usize, + limit: usize, + ) -> Result> { + let mut query = table.query().limit(limit).offset(offset); + if let Some(filter) = filter { + query = query.only_if(filter.as_str()); + } + let mut stream = query.execute().await?; + let mut results = Vec::new(); + while let Some(batch) = stream.try_next().await? { + let mut docs = self.batch_to_docs(&batch)?; + results.append(&mut docs); + } + Ok(results) + } + + async fn legacy_table_if_exists(&self) -> Result> { + self.open_table_if_exists().await + } + /// Try to open the table without creating it. /// Returns `Ok(None)` when the table genuinely does not exist. /// Propagates real errors (I/O, corruption, permission) as `Err`. @@ -651,16 +1219,6 @@ impl StorageManager { } } - async fn open_existing_table(&self) -> Result
{ - self.open_table_if_exists().await?.ok_or_else(|| { - anyhow!( - "Vector table '{}' not found at {}. Index data first so rust-memex can use the stored embedding dimension instead of guessing.", - self.collection_name, - self.lance_path - ) - }) - } - /// Create the LanceDB schema with onion slice fields and content hash fn create_schema(dim: usize) -> Schema { Schema::new(vec![ @@ -681,11 +1239,72 @@ impl StorageManager { Field::new("parent_id", DataType::Utf8, true), // Parent slice ID Field::new("children_ids", DataType::Utf8, true), // JSON array of children IDs Field::new("keywords", DataType::Utf8, true), // JSON array of keywords - // Deduplication field (v3 schema) - Field::new("content_hash", DataType::Utf8, true), // SHA256 hash for exact-match dedup + // Per-chunk dedup hash (v3 schema; v4 narrows semantic to chunk text) + Field::new("content_hash", DataType::Utf8, true), // SHA256 of THIS chunk's text + // Source-document hash (v4 schema) — same value across all 4 layers + // from one source. Drives pre-index dedup so we never re-embed an + // already-ingested file. + Field::new("source_hash", DataType::Utf8, true), // SHA256 of source document text ]) } + async fn ensure_hash_schema_columns(&self, table: &Table) -> Result<()> { + let missing = Self::missing_required_columns(table, SchemaVersion::current()).await?; + + if missing.is_empty() { + return Ok(()); + } + + let missing_columns = missing + .iter() + .map(|field| field.name().to_string()) + .collect::>(); + let error = SchemaMismatchWriteError::new( + self.collection_name.clone(), + self.lance_path.clone(), + missing_columns, + "table is older than the current writer schema", + ); + self.log_schema_mismatch(&error); + Err(error.into()) + } + + fn map_lancedb_write_error(&self, error: lancedb::error::Error) -> anyhow::Error { + let message = match &error { + lancedb::error::Error::Lance { source } => source.to_string(), + lancedb::error::Error::Schema { message } => message.clone(), + lancedb::error::Error::Arrow { source } => source.to_string(), + _ => return error.into(), + }; + + if !is_schema_mismatch_message(&message) { + return error.into(); + } + + let missing_columns = extract_missing_columns(&message); + let error = SchemaMismatchWriteError::new( + self.collection_name.clone(), + self.lance_path.clone(), + missing_columns, + message, + ); + self.log_schema_mismatch(&error); + error.into() + } + + fn log_schema_mismatch(&self, error: &SchemaMismatchWriteError) { + error!( + error_kind = "schema_mismatch", + table = %error.table_name(), + db_path = %error.db_path(), + missing_columns = ?error.missing_columns(), + remediation = %error.remediation(), + file = file!(), + line = line!(), + "write-path schema mismatch" + ); + } + fn docs_to_batch(&self, documents: &[ChromaDocument], dim: usize) -> Result { let ids = documents.iter().map(|d| d.id.as_str()).collect::>(); let namespaces = documents @@ -721,11 +1340,14 @@ impl StorageManager { .iter() .map(|d| serde_json::to_string(&d.keywords).unwrap_or_else(|_| "[]".to_string())) .collect(); - // Content hash for deduplication + // Per-chunk content hash for chunk-level dedup let content_hashes: Vec> = documents .iter() .map(|d| d.content_hash.as_deref()) .collect(); + // Source-document hash for pre-index source-level dedup (v4 schema) + let source_hashes: Vec> = + documents.iter().map(|d| d.source_hash.as_deref()).collect(); let schema = Arc::new(Self::create_schema(dim)); @@ -753,15 +1375,17 @@ impl StorageManager { Arc::new(StringArray::from( keywords_json.iter().map(|s| s.as_str()).collect::>(), )), - // Content hash for deduplication + // Per-chunk content hash for chunk-level dedup Arc::new(StringArray::from(content_hashes)), + // Source-document hash for source-level dedup (v4 schema) + Arc::new(StringArray::from(source_hashes)), ], )?; - Ok(RecordBatchIterator::new( + Ok(Box::new(RecordBatchIterator::new( vec![Ok(batch)].into_iter(), schema, - )) + ))) } fn batch_to_docs(&self, batch: &RecordBatch) -> Result> { @@ -803,6 +1427,10 @@ impl StorageManager { let content_hash_col = batch .column_by_name("content_hash") .and_then(|c| c.as_any().downcast_ref::()); + // Source hash column (v4 schema) — optional for pre-v4 tables. + let source_hash_col = batch + .column_by_name("source_hash") + .and_then(|c| c.as_any().downcast_ref::()); let dim = vector_col.value_length() as usize; let values = vector_col @@ -872,6 +1500,14 @@ impl StorageManager { } }); + let source_hash = source_hash_col.and_then(|col| { + if col.is_null(i) { + None + } else { + Some(col.value(i).to_string()) + } + }); + docs.push(ChromaDocument { id, namespace, @@ -883,6 +1519,7 @@ impl StorageManager { children_ids, keywords, content_hash, + source_hash, }); } Ok(docs) @@ -893,16 +1530,21 @@ impl StorageManager { namespace: &str, filter: &str, ) -> Result> { - let table = match self.open_table_if_exists().await? { - Some(t) => t, - None => return Ok(vec![]), - }; - let combined = format!("{} AND ({})", self.namespace_filter(namespace), filter); - let mut stream = table.query().only_if(combined.as_str()).execute().await?; let mut results = Vec::new(); - while let Some(batch) = stream.try_next().await? { - let mut docs = self.batch_to_docs(&batch)?; - results.append(&mut docs); + if let Some(table) = self.open_namespace_table_if_exists(namespace).await? { + let mut stream = table.query().only_if(filter).execute().await?; + while let Some(batch) = stream.try_next().await? { + let mut docs = self.batch_to_docs(&batch)?; + results.append(&mut docs); + } + } + if let Some(table) = self.legacy_table_if_exists().await? { + let combined = format!("{} AND ({})", self.namespace_filter(namespace), filter); + let mut stream = table.query().only_if(combined.as_str()).execute().await?; + while let Some(batch) = stream.try_next().await? { + let mut docs = self.batch_to_docs(&batch)?; + results.append(&mut docs); + } } Ok(results) } @@ -918,31 +1560,68 @@ impl StorageManager { if embedding.is_empty() { return Ok(vec![]); } - let dim = embedding.len(); - let table = self.ensure_table(dim).await?; - - let mut query = table.query(); - // Build combined filter let mut filters = Vec::new(); - if let Some(ns) = namespace { - filters.push(self.namespace_filter(ns)); - } if let Some(layer) = layer_filter { filters.push(self.layer_filter(layer)); } - - if !filters.is_empty() { - let combined = filters.join(" AND "); - query = query.only_if(combined.as_str()); - } - - let mut stream = query.nearest_to(embedding)?.limit(k).execute().await?; - let mut results = Vec::new(); - while let Some(batch) = stream.try_next().await? { - let mut docs = self.batch_to_docs(&batch)?; - results.append(&mut docs); + + if let Some(ns) = namespace { + if let Some(table) = self.open_namespace_table_if_exists(ns).await? { + let mut query = table.query(); + if !filters.is_empty() { + let combined = filters.join(" AND "); + query = query.only_if(combined.as_str()); + } + let mut stream = query + .nearest_to(embedding.clone())? + .limit(k) + .execute() + .await?; + while let Some(batch) = stream.try_next().await? { + let mut docs = self.batch_to_docs(&batch)?; + results.append(&mut docs); + } + } + if let Some(table) = self.legacy_table_if_exists().await? { + let mut legacy_filters = vec![self.namespace_filter(ns)]; + legacy_filters.extend(filters.clone()); + let combined = legacy_filters.join(" AND "); + let mut stream = table + .query() + .only_if(combined.as_str()) + .nearest_to(embedding)? + .limit(k) + .execute() + .await?; + while let Some(batch) = stream.try_next().await? { + let mut docs = self.batch_to_docs(&batch)?; + results.append(&mut docs); + } + } + results.truncate(k); + } else { + for table_name in self.data_table_names().await? { + let Some(table) = self.open_named_table_if_exists(&table_name).await? else { + continue; + }; + let mut query = table.query(); + if !filters.is_empty() { + let combined = filters.join(" AND "); + query = query.only_if(combined.as_str()); + } + let mut stream = query + .nearest_to(embedding.clone())? + .limit(k) + .execute() + .await?; + while let Some(batch) = stream.try_next().await? { + let mut docs = self.batch_to_docs(&batch)?; + results.append(&mut docs); + } + } + results.truncate(k); } debug!( "Lance returned {} results (layer filter: {:?})", @@ -1016,6 +1695,10 @@ impl StorageManager { format!("content_hash = '{}'", hash.replace('\'', "''")) } + fn source_hash_filter(&self, hash: &str) -> String { + format!("source_hash = '{}'", hash.replace('\'', "''")) + } + /// Check if the table schema has content_hash column (schema v3+) async fn table_has_content_hash(table: &Table) -> bool { table @@ -1025,42 +1708,106 @@ impl StorageManager { .unwrap_or(false) } + /// Check if the table schema has source_hash column (schema v4+) + async fn table_has_source_hash(table: &Table) -> bool { + table + .schema() + .await + .map(|schema| schema.field_with_name("source_hash").is_ok()) + .unwrap_or(false) + } + /// Check if a content hash already exists in a namespace (for exact-match deduplication) /// /// Returns Ok(false) if: /// - Table doesn't exist yet /// - Table has old schema without content_hash column (graceful degradation) pub async fn has_content_hash(&self, namespace: &str, hash: &str) -> Result { - let table = match self.open_table_if_exists().await? { - Some(t) => t, - None => return Ok(false), - }; + let hash_filter = self.content_hash_filter(hash); + if let Some(table) = self.open_namespace_table_if_exists(namespace).await? + && Self::table_has_content_hash(&table).await + { + let mut stream = table + .query() + .only_if(hash_filter.as_str()) + .limit(1) + .execute() + .await?; + if let Some(batch) = stream.try_next().await? { + return Ok(batch.num_rows() > 0); + } + } - // Graceful handling of old schema without content_hash column - if !Self::table_has_content_hash(&table).await { - tracing::warn!( - "Table '{}' has old schema without content_hash column. \ - Deduplication disabled. Consider re-indexing with new schema.", - self.collection_name - ); - return Ok(false); // Can't check for duplicates, treat as new + if let Some(table) = self.legacy_table_if_exists().await? { + if !Self::table_has_content_hash(&table).await { + tracing::warn!( + "Table '{}' has old schema without content_hash column. \ + Deduplication disabled. Consider re-indexing with new schema.", + self.collection_name + ); + return Ok(false); // Can't check for duplicates, treat as new + } + + let filter = format!("{} AND {}", self.namespace_filter(namespace), hash_filter); + let mut stream = table + .query() + .only_if(filter.as_str()) + .limit(1) + .execute() + .await?; + + if let Some(batch) = stream.try_next().await? { + return Ok(batch.num_rows() > 0); + } } - let filter = format!( - "{} AND {}", - self.namespace_filter(namespace), - self.content_hash_filter(hash) - ); + Ok(false) + } - let mut stream = table - .query() - .only_if(filter.as_str()) - .limit(1) - .execute() - .await?; + /// Check if any chunk in `namespace` already references the given source-document + /// hash. Used by the indexing pipeline to skip re-embedding files that were + /// already ingested (P4 — pre-index source-level dedup). + /// + /// Returns Ok(false) if the table doesn't exist yet, or if the table is on a + /// pre-v4 schema without the `source_hash` column (graceful degradation — + /// older namespaces should be backfilled via `/admin/backfill-hashes`). + pub async fn has_source_hash(&self, namespace: &str, hash: &str) -> Result { + let hash_filter = self.source_hash_filter(hash); + if let Some(table) = self.open_namespace_table_if_exists(namespace).await? + && Self::table_has_source_hash(&table).await + { + let mut stream = table + .query() + .only_if(hash_filter.as_str()) + .limit(1) + .execute() + .await?; + if let Some(batch) = stream.try_next().await? { + return Ok(batch.num_rows() > 0); + } + } - if let Some(batch) = stream.try_next().await? { - return Ok(batch.num_rows() > 0); + if let Some(table) = self.legacy_table_if_exists().await? { + if !Self::table_has_source_hash(&table).await { + tracing::debug!( + "Table '{}' has pre-v4 schema without source_hash column. \ + Source-level dedup disabled until backfill.", + self.collection_name + ); + return Ok(false); + } + + let filter = format!("{} AND {}", self.namespace_filter(namespace), hash_filter); + let mut stream = table + .query() + .only_if(filter.as_str()) + .limit(1) + .execute() + .await?; + + if let Some(batch) = stream.try_next().await? { + return Ok(batch.num_rows() > 0); + } } Ok(false) @@ -1079,49 +1826,68 @@ impl StorageManager { return Ok(vec![]); } - let table = match self.open_table_if_exists().await? { - Some(t) => t, - None => return Ok(hashes.iter().collect()), - }; - - // Graceful handling of old schema without content_hash column - if !Self::table_has_content_hash(&table).await { - tracing::warn!( - "Table '{}' has old schema without content_hash column. \ - Deduplication disabled. Consider re-indexing with new schema.", - self.collection_name - ); - return Ok(hashes.iter().collect()); // All are "new" since we can't check - } - // Query for existing hashes in this namespace // We build a filter with OR conditions for all hashes let hash_conditions: Vec = hashes.iter().map(|h| self.content_hash_filter(h)).collect(); - let filter = format!( - "{} AND ({})", - self.namespace_filter(namespace), - hash_conditions.join(" OR ") - ); + let mut existing_hashes = std::collections::HashSet::new(); - let mut stream = table - .query() - .only_if(filter.as_str()) - .limit(hashes.len()) - .execute() - .await?; + if let Some(table) = self.open_namespace_table_if_exists(namespace).await? + && Self::table_has_content_hash(&table).await + { + let filter = hash_conditions.join(" OR "); + let mut stream = table + .query() + .only_if(filter.as_str()) + .limit(hashes.len()) + .execute() + .await?; + while let Some(batch) = stream.try_next().await? { + if let Some(hash_col) = batch + .column_by_name("content_hash") + .and_then(|c| c.as_any().downcast_ref::()) + { + for i in 0..batch.num_rows() { + if !hash_col.is_null(i) { + existing_hashes.insert(hash_col.value(i).to_string()); + } + } + } + } + } - // Collect existing hashes from results - let mut existing_hashes = std::collections::HashSet::new(); - while let Some(batch) = stream.try_next().await? { - if let Some(hash_col) = batch - .column_by_name("content_hash") - .and_then(|c| c.as_any().downcast_ref::()) - { - for i in 0..batch.num_rows() { - if !hash_col.is_null(i) { - existing_hashes.insert(hash_col.value(i).to_string()); + if let Some(table) = self.legacy_table_if_exists().await? { + // Graceful handling of old schema without content_hash column + if !Self::table_has_content_hash(&table).await { + tracing::warn!( + "Table '{}' has old schema without content_hash column. \ + Deduplication disabled. Consider re-indexing with new schema.", + self.collection_name + ); + return Ok(hashes.iter().collect()); // All are "new" since we can't check + } + + let filter = format!( + "{} AND ({})", + self.namespace_filter(namespace), + hash_conditions.join(" OR ") + ); + let mut stream = table + .query() + .only_if(filter.as_str()) + .limit(hashes.len()) + .execute() + .await?; + while let Some(batch) = stream.try_next().await? { + if let Some(hash_col) = batch + .column_by_name("content_hash") + .and_then(|c| c.as_any().downcast_ref::()) + { + for i in 0..batch.num_rows() { + if !hash_col.is_null(i) { + existing_hashes.insert(hash_col.value(i).to_string()); + } } } } @@ -1140,8 +1906,16 @@ impl StorageManager { /// Run all optimizations (compact + prune old versions) pub async fn optimize(&self) -> Result { - let table = self.open_existing_table().await?; - let stats = table.optimize(OptimizeAction::All).await?; + let mut stats = OptimizeStats { + compaction: None, + prune: None, + }; + for table_name in self.data_table_names().await? { + let Some(table) = self.open_named_table_if_exists(&table_name).await? else { + continue; + }; + stats = table.optimize(OptimizeAction::All).await?; + } info!( "Optimize complete: compaction={:?}, prune={:?}", stats.compaction, stats.prune @@ -1151,41 +1925,62 @@ impl StorageManager { /// Compact small files into larger ones for better performance pub async fn compact(&self) -> Result { - let table = self.open_existing_table().await?; - let stats = table - .optimize(OptimizeAction::Compact { - options: Default::default(), - remap_options: None, - }) - .await?; + let mut stats = OptimizeStats { + compaction: None, + prune: None, + }; + for table_name in self.data_table_names().await? { + let Some(table) = self.open_named_table_if_exists(&table_name).await? else { + continue; + }; + stats = table + .optimize(OptimizeAction::Compact { + options: Default::default(), + remap_options: None, + }) + .await?; + } info!("Compaction complete: {:?}", stats.compaction); Ok(stats) } /// Remove old versions older than specified duration (default: 7 days) pub async fn cleanup(&self, older_than_days: Option) -> Result { - let table = self.open_existing_table().await?; let days = older_than_days.unwrap_or(7) as i64; let duration = chrono::TimeDelta::days(days); - let stats = table - .optimize(OptimizeAction::Prune { - older_than: Some(duration), - delete_unverified: Some(false), - error_if_tagged_old_versions: None, - }) - .await?; + let mut stats = OptimizeStats { + compaction: None, + prune: None, + }; + for table_name in self.data_table_names().await? { + let Some(table) = self.open_named_table_if_exists(&table_name).await? else { + continue; + }; + stats = table + .optimize(OptimizeAction::Prune { + older_than: Some(duration), + delete_unverified: Some(false), + error_if_tagged_old_versions: None, + }) + .await?; + } info!("Cleanup complete: {:?}", stats.prune); Ok(stats) } /// Get table statistics (row count, fragments, etc.) pub async fn stats(&self) -> Result { - let table = self.open_existing_table().await?; - let row_count = table.count_rows(None).await?; + let table_names = self.data_table_names().await?; + let mut row_count = 0usize; + let mut version_count = 0usize; - // Get version count - let versions = table.list_versions().await.unwrap_or_default(); - let version_count = versions.len(); + for table_name in &table_names { + let Some(table) = self.open_named_table_if_exists(table_name).await? else { + continue; + }; + row_count += table.count_rows(None).await.unwrap_or(0); + version_count += table.list_versions().await.unwrap_or_default().len(); + } Ok(TableStats { row_count, @@ -1197,12 +1992,14 @@ impl StorageManager { /// Count rows in a specific namespace pub async fn count_namespace(&self, namespace: &str) -> Result { - let table = match self.open_table_if_exists().await? { - Some(table) => table, - None => return Ok(0), - }; - let filter = self.namespace_filter(namespace); - let count = table.count_rows(Some(filter)).await?; + let mut count = 0usize; + if let Some(table) = self.open_namespace_table_if_exists(namespace).await? { + count += table.count_rows(None).await?; + } + if let Some(table) = self.legacy_table_if_exists().await? { + let filter = self.namespace_filter(namespace); + count += table.count_rows(Some(filter)).await?; + } Ok(count) } @@ -1211,20 +2008,7 @@ impl StorageManager { /// Note: This uses a full table scan with namespace filter. /// For very large namespaces, consider batching. pub async fn get_all_in_namespace(&self, namespace: &str) -> Result> { - let table = match self.open_table_if_exists().await? { - Some(t) => t, - None => return Ok(vec![]), - }; - - let filter = self.namespace_filter(namespace); - let mut stream = table.query().only_if(filter.as_str()).execute().await?; - - let mut results = Vec::new(); - while let Some(batch) = stream.try_next().await? { - let mut docs = self.batch_to_docs(&batch)?; - results.append(&mut docs); - } - + let results = self.all_documents(Some(namespace), 100_000).await?; debug!( "Retrieved {} documents from namespace '{}'", results.len(), @@ -1560,18 +2344,25 @@ impl StorageManager { let mut namespace_counts: std::collections::HashMap = std::collections::HashMap::new(); - const PAGE_SIZE: usize = 5000; - let mut offset = 0; - loop { - let page = self.all_documents_page(None, offset, PAGE_SIZE).await?; - let page_len = page.len(); - for doc in &page { - *namespace_counts.entry(doc.namespace.clone()).or_insert(0) += 1; - } - if page_len < PAGE_SIZE { - break; + for table_name in self.data_table_names().await? { + let Some(table) = self.open_named_table_if_exists(&table_name).await? else { + continue; + }; + const PAGE_SIZE: usize = 5000; + let mut offset = 0; + loop { + let page = self + .query_table_page(&table, None, offset, PAGE_SIZE) + .await?; + let page_len = page.len(); + for doc in &page { + *namespace_counts.entry(doc.namespace.clone()).or_insert(0) += 1; + } + if page_len < PAGE_SIZE { + break; + } + offset += page_len; } - offset += page_len; } let mut namespaces: Vec<(String, usize)> = namespace_counts.into_iter().collect(); @@ -1579,3 +2370,87 @@ impl StorageManager { Ok(namespaces) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tempfile::TempDir; + + #[test] + fn flat_documents_preserve_separate_chunk_and_source_hashes() { + let doc = ChromaDocument::new_flat_with_hashes( + "doc-1".to_string(), + "kb:transcripts".to_string(), + vec![0.0, 1.0], + json!({"path": "sample.md"}), + "outer summary chunk".to_string(), + "chunk-sha256".to_string(), + Some("source-sha256".to_string()), + ); + + assert_eq!(doc.content_hash.as_deref(), Some("chunk-sha256")); + assert_eq!(doc.source_hash.as_deref(), Some("source-sha256")); + assert_ne!(doc.content_hash, doc.source_hash); + } + + #[tokio::test] + async fn namespace_writes_use_separate_lance_tables_and_keep_contracts() { + let tmp = TempDir::new().expect("temp dir"); + let db_path = tmp.path().join("lancedb"); + let storage = StorageManager::new_lance_only(db_path.to_str().unwrap()) + .await + .expect("storage"); + + let embedding = vec![0.25_f32; 8]; + storage + .add_to_store(vec![ + ChromaDocument::new_flat( + "shared-id".to_string(), + "kb:alpha".to_string(), + embedding.clone(), + json!({"ns": "alpha"}), + "alpha memory".to_string(), + ), + ChromaDocument::new_flat( + "shared-id".to_string(), + "kb:beta".to_string(), + embedding.clone(), + json!({"ns": "beta"}), + "beta memory".to_string(), + ), + ]) + .await + .expect("write two namespaces"); + + let table_names = storage.lance.table_names().execute().await.expect("tables"); + let namespace_tables = table_names + .iter() + .filter(|name| StorageManager::is_namespace_table_name(name)) + .count(); + assert_eq!(namespace_tables, 2, "{table_names:?}"); + assert!(!table_names.iter().any(|name| name == DEFAULT_TABLE_NAME)); + + assert_eq!(storage.count_namespace("kb:alpha").await.unwrap(), 1); + assert_eq!(storage.count_namespace("kb:beta").await.unwrap(), 1); + assert_eq!( + storage + .get_document("kb:alpha", "shared-id") + .await + .unwrap() + .unwrap() + .document, + "alpha memory" + ); + assert_eq!( + storage + .search_store(Some("kb:beta"), embedding, 10) + .await + .unwrap() + .into_iter() + .map(|doc| doc.document) + .collect::>(), + vec!["beta memory"] + ); + } +} diff --git a/src/tests/memory.rs b/crates/rust-memex/src/tests/memory.rs similarity index 100% rename from src/tests/memory.rs rename to crates/rust-memex/src/tests/memory.rs diff --git a/src/tests/mod.rs b/crates/rust-memex/src/tests/mod.rs similarity index 100% rename from src/tests/mod.rs rename to crates/rust-memex/src/tests/mod.rs diff --git a/src/tests/transport_parity.rs b/crates/rust-memex/src/tests/transport_parity.rs similarity index 97% rename from src/tests/transport_parity.rs rename to crates/rust-memex/src/tests/transport_parity.rs index 9d3e51d..57fc557 100644 --- a/src/tests/transport_parity.rs +++ b/crates/rust-memex/src/tests/transport_parity.rs @@ -59,8 +59,6 @@ fn tool_names() -> Vec { const EXPECTED_TOOLS: &[&str] = &[ "health", "rag_index", - "rag_index_text", - "rag_search", "memory_upsert", "memory_get", "memory_search", @@ -220,7 +218,7 @@ fn jsonrpc_success_includes_id_when_present() { let resp = jsonrpc_success(&json!(42), json!({"ok": true})); assert_eq!(resp["jsonrpc"], "2.0"); assert_eq!(resp["id"], 42); - assert_eq!(resp["result"]["ok"], true); + assert!(resp["result"]["ok"].as_bool().unwrap()); assert!(resp.get("error").is_none()); } @@ -273,9 +271,7 @@ fn jsonrpc_error_omits_id_when_none() { /// Returns None if the embedding server is unreachable (test should skip). async fn try_build_mcp_core() -> Option { use crate::{ - EmbeddingClient, EmbeddingConfig, ProviderConfig, - rag::RAGPipeline, - security::{NamespaceAccessManager, NamespaceSecurityConfig}, + EmbeddingClient, EmbeddingConfig, ProviderConfig, auth::AuthManager, rag::RAGPipeline, storage::StorageManager, }; use std::sync::Arc; @@ -307,6 +303,7 @@ async fn try_build_mcp_core() -> Option { let db_path = tmp.path().join(".lancedb"); // Leak the tempdir so it outlives the test — the OS cleans up /tmp anyway let db_path_str = db_path.to_string_lossy().to_string(); + let tokens_path = tmp.path().join("tokens.json").to_string_lossy().to_string(); std::mem::forget(tmp); let storage = Arc::new(StorageManager::new(&db_path_str).await.ok()?); @@ -316,10 +313,10 @@ async fn try_build_mcp_core() -> Option { .ok()?, ); - let access_manager = Arc::new(NamespaceAccessManager::new( - NamespaceSecurityConfig::default(), - )); - let _ = access_manager.init().await; + // Track C: AuthManager persists to `tokens.json` in the tempdir. Matches + // the legacy "security disabled, open access" default (empty store), + // while still allowing create_token to succeed. + let auth_manager = Arc::new(AuthManager::new(tokens_path, None)); Some(McpCore::new( rag, @@ -327,7 +324,7 @@ async fn try_build_mcp_core() -> Option { embedding_client, 5 * 1024 * 1024, vec![], - access_manager, + auth_manager, )) } @@ -609,12 +606,7 @@ async fn health_tool_transport_field_difference_is_intentional() { /// Build an McpCore with a stub embedding client. /// These tests cover protocol dispatch paths that don't touch embeddings. async fn build_mcp_core_stub() -> McpCore { - use crate::{ - EmbeddingClient, - rag::RAGPipeline, - security::{NamespaceAccessManager, NamespaceSecurityConfig}, - storage::StorageManager, - }; + use crate::{EmbeddingClient, auth::AuthManager, rag::RAGPipeline, storage::StorageManager}; use std::sync::Arc; use tokio::sync::Mutex; @@ -623,6 +615,9 @@ async fn build_mcp_core_stub() -> McpCore { let tmp = tempfile::tempdir().expect("tempdir"); let db_path = tmp.path().join(".lancedb"); let db_path_str = db_path.to_string_lossy().to_string(); + // Track C: AuthManager persists tokens to disk on create/revoke — give it + // a real tempdir path so save() doesn't error with an empty string. + let tokens_path = tmp.path().join("tokens.json").to_string_lossy().to_string(); std::mem::forget(tmp); let storage = Arc::new( @@ -636,10 +631,7 @@ async fn build_mcp_core_stub() -> McpCore { .expect("RAGPipeline::new"), ); - let access_manager = Arc::new(NamespaceAccessManager::new( - NamespaceSecurityConfig::default(), - )); - let _ = access_manager.init().await; + let auth_manager = Arc::new(AuthManager::new(tokens_path, None)); McpCore::new( rag, @@ -647,7 +639,7 @@ async fn build_mcp_core_stub() -> McpCore { embedding_client, 5 * 1024 * 1024, vec![], - access_manager, + auth_manager, ) } diff --git a/src/tools.rs b/crates/rust-memex/src/tools.rs similarity index 99% rename from src/tools.rs rename to crates/rust-memex/src/tools.rs index 76a04c2..a5dfe78 100644 --- a/src/tools.rs +++ b/crates/rust-memex/src/tools.rs @@ -393,7 +393,7 @@ mod tests { #[test] fn test_tool_definitions_count() { let tools = tool_definitions(); - assert_eq!(tools.len(), 14); + assert_eq!(tools.len(), 12); } #[test] @@ -403,8 +403,6 @@ mod tests { assert!(names.contains(&"health")); assert!(names.contains(&"rag_index")); - assert!(names.contains(&"rag_index_text")); - assert!(names.contains(&"rag_search")); assert!(names.contains(&"memory_upsert")); assert!(names.contains(&"memory_search")); assert!(names.contains(&"memory_get")); diff --git a/src/tui/app.rs b/crates/rust-memex/src/tui/app.rs similarity index 98% rename from src/tui/app.rs rename to crates/rust-memex/src/tui/app.rs index 048eb76..bc57609 100644 --- a/src/tui/app.rs +++ b/crates/rust-memex/src/tui/app.rs @@ -17,8 +17,9 @@ use crate::tui::host_detection::{ }; use crate::tui::indexer::{ DataSetupOption, DataSetupState, DataSetupSubStep, FanOut, ImportMode, IndexControl, - IndexEventSink, IndexTelemetrySnapshot, SharedIndexTelemetry, TracingSink, TuiTelemetrySink, - collect_indexable_files, import_lancedb, new_index_telemetry, start_indexing, validate_path, + IndexEventSink, IndexTelemetrySnapshot, IndexingJob, SharedIndexTelemetry, TracingSink, + TuiTelemetrySink, collect_indexable_files, import_lancedb, new_index_telemetry, start_indexing, + validate_path, }; use crate::tui::monitor::{MonitorSnapshot, spawn_monitor}; use anyhow::{Result, anyhow}; @@ -1136,11 +1137,8 @@ impl App { KeyCode::Up | KeyCode::Char('k') => self.handle_up(), KeyCode::Down | KeyCode::Char('j') => self.handle_down(), KeyCode::Char(' ') => self.handle_space(), - KeyCode::Char('r') => { - // Retry health check - if self.step == WizardStep::HealthCheck && !self.health_running { - self.trigger_health_check(); - } + KeyCode::Char('r') if self.step == WizardStep::HealthCheck && !self.health_running => { + self.trigger_health_check(); } _ => {} } @@ -1247,15 +1245,11 @@ impl App { self.editing_field = Some(self.focus); self.input_buffer = self.get_field_value(self.focus); } - WizardStep::HostSelection => { - if self.focus < self.hosts.len() { - self.toggle_host(self.focus); - } + WizardStep::HostSelection if self.focus < self.hosts.len() => { + self.toggle_host(self.focus); } - WizardStep::HealthCheck => { - if !self.health_running { - self.trigger_health_check(); - } + WizardStep::HealthCheck if !self.health_running => { + self.trigger_health_check(); } WizardStep::DataSetup => { self.handle_data_setup_enter(); @@ -1553,14 +1547,16 @@ impl App { mpsc::channel(crate::tui::indexer::INDEX_CONTROL_CHANNEL_CAPACITY); self.index_task = Some(start_indexing( - path, - files, - namespace.clone(), - self.embedding_config.clone(), - self.memex_cfg.resolved_db_path(), + IndexingJob { + source_dir: path, + files, + namespace: namespace.clone(), + embedding_config: self.embedding_config.clone(), + db_path: self.memex_cfg.resolved_db_path(), + initial_parallelism: self.index_parallelism, + }, sink, control_rx, - self.index_parallelism, )); let (monitor_rx, monitor_task) = spawn_monitor(Duration::from_secs(1)); diff --git a/src/tui/detection.rs b/crates/rust-memex/src/tui/detection.rs similarity index 100% rename from src/tui/detection.rs rename to crates/rust-memex/src/tui/detection.rs diff --git a/src/tui/health.rs b/crates/rust-memex/src/tui/health.rs similarity index 100% rename from src/tui/health.rs rename to crates/rust-memex/src/tui/health.rs diff --git a/src/tui/host_detection.rs b/crates/rust-memex/src/tui/host_detection.rs similarity index 98% rename from src/tui/host_detection.rs rename to crates/rust-memex/src/tui/host_detection.rs index 5726e2f..bd31899 100644 --- a/src/tui/host_detection.rs +++ b/crates/rust-memex/src/tui/host_detection.rs @@ -205,8 +205,10 @@ pub fn get_extended_host_config_path(kind: ExtendedHostKind) -> Option<(PathBuf, fn parse_toml_mcp_servers(content: &str) -> Vec { let mut servers = Vec::new(); - if let Ok(value) = content.parse::() - && let Some(mcp_servers) = value.get("mcp_servers").and_then(|v| v.as_table()) + // toml 1.0: `Value` parser expects a single value, not a document. + // Parse the document as `Table` (alias for `Map`). + if let Ok(root) = content.parse::() + && let Some(mcp_servers) = root.get("mcp_servers").and_then(|v| v.as_table()) { for (name, config) in mcp_servers { let command = config @@ -548,8 +550,10 @@ fn merge_json_config(existing_content: &str, entry: &McpServerEntry) -> Result Result { - let mut config: toml::Value = if existing_content.trim().is_empty() { - toml::Value::Table(toml::map::Map::new()) + // toml 1.0: parse the document as `Table`. `Value` parser only accepts a + // single value, not a full document. + let mut config: toml::Table = if existing_content.trim().is_empty() { + toml::Table::new() } else { existing_content .parse() @@ -557,16 +561,15 @@ fn merge_toml_config(existing_content: &str, entry: &McpServerEntry) -> Result, + pub namespace: String, + pub embedding_config: EmbeddingConfig, + pub db_path: String, + pub initial_parallelism: usize, +} + /// Start the concurrent indexing scheduler. -#[allow(clippy::too_many_arguments)] pub fn start_indexing( - source_dir: PathBuf, - files: Vec, - namespace: String, - embedding_config: EmbeddingConfig, - db_path: String, + job: IndexingJob, sink: Arc, control_rx: mpsc::Receiver, - initial_parallelism: usize, ) -> JoinHandle> { tokio::spawn(async move { + let IndexingJob { + source_dir, + files, + namespace, + embedding_config, + db_path, + initial_parallelism, + } = job; + let expanded_db_path = shellexpand::tilde(&db_path).to_string(); let storage = Arc::new(StorageManager::new_lance_only(&expanded_db_path).await?); storage.ensure_collection().await?; @@ -123,14 +140,16 @@ pub fn start_indexing( Ok(IndexResult::Indexed { chunks_indexed, content_hash, + embedder_ms, + tokens_estimated, }) => FileOutcome::Indexed { file_index, path, chunks_indexed, content_hash, duration_ms: started_at.elapsed().as_millis() as u64, - embedder_ms: None, - tokens_estimated: None, + embedder_ms, + tokens_estimated, }, Ok(IndexResult::Skipped { reason, @@ -200,6 +219,8 @@ async fn run_scheduler_with_processor( }); emit_stats_tick(&state, &sink); + let mut stats_interval = tokio::time::interval(tokio::time::Duration::from_millis(500)); + loop { drain_control_queue(&mut state, &sink, &mut control_rx); @@ -209,6 +230,9 @@ async fn run_scheduler_with_processor( } tokio::select! { + _ = stats_interval.tick() => { + emit_stats_tick(&state, &sink); + } Some(control) = control_rx.recv() => { handle_control(&mut state, &sink, control); } @@ -225,6 +249,9 @@ async fn run_scheduler_with_processor( tokio::pin!(resume_wait); tokio::select! { + _ = stats_interval.tick() => { + emit_stats_tick(&state, &sink); + } Some(control) = control_rx.recv() => { handle_control(&mut state, &sink, control); } @@ -243,6 +270,9 @@ async fn run_scheduler_with_processor( } tokio::select! { + _ = stats_interval.tick() => { + emit_stats_tick(&state, &sink); + } Some(control) = control_rx.recv() => { handle_control(&mut state, &sink, control); } diff --git a/src/tui/indexer/sinks.rs b/crates/rust-memex/src/tui/indexer/sinks.rs similarity index 100% rename from src/tui/indexer/sinks.rs rename to crates/rust-memex/src/tui/indexer/sinks.rs diff --git a/src/tui/indexer/state.rs b/crates/rust-memex/src/tui/indexer/state.rs similarity index 100% rename from src/tui/indexer/state.rs rename to crates/rust-memex/src/tui/indexer/state.rs diff --git a/src/tui/mod.rs b/crates/rust-memex/src/tui/mod.rs similarity index 86% rename from src/tui/mod.rs rename to crates/rust-memex/src/tui/mod.rs index a4b4c65..ad5e81a 100644 --- a/src/tui/mod.rs +++ b/crates/rust-memex/src/tui/mod.rs @@ -31,7 +31,8 @@ pub use health::{CheckStatus, HealthCheckItem, HealthCheckResult, HealthChecker} pub use host_detection::{HostDetection, detect_hosts, write_mux_service_config}; pub use indexer::{ DataSetupOption, DataSetupState, DataSetupSubStep, FanOut, ImportMode, IndexControl, - IndexEvent, IndexEventSink, IndexTelemetrySnapshot, SharedIndexTelemetry, TracingSink, - TuiTelemetrySink, collect_indexable_files, import_lancedb, start_indexing, validate_path, + IndexEvent, IndexEventSink, IndexTelemetrySnapshot, IndexingJob, SharedIndexTelemetry, + TracingSink, TuiTelemetrySink, collect_indexable_files, import_lancedb, start_indexing, + validate_path, }; pub use monitor::{GpuStatus, MonitorSnapshot, spawn_monitor}; diff --git a/src/tui/monitor.rs b/crates/rust-memex/src/tui/monitor.rs similarity index 83% rename from src/tui/monitor.rs rename to crates/rust-memex/src/tui/monitor.rs index 0d1d897..9043ae4 100644 --- a/src/tui/monitor.rs +++ b/crates/rust-memex/src/tui/monitor.rs @@ -7,6 +7,7 @@ use sysinfo::{Pid, ProcessesToUpdate, System}; use tokio::sync::watch; use tokio::task::JoinHandle; +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] const GPU_CLASSES: &[&str] = &["AGXAcceleratorG15X", "IOAccelerator"]; const EMBEDDER_PROCESS_NAMES: &[&str] = &["ollama", "llama-server", "mlx_server", "mlx-server"]; @@ -149,43 +150,49 @@ struct GpuMetrics { fn probe_gpu() -> Result { #[cfg(not(target_os = "macos"))] - return Err(GpuStatus::Unavailable { - reason: "GPU telemetry only supported on macOS via ioreg".to_string(), - }); + { + Err(GpuStatus::Unavailable { + reason: "GPU telemetry only supported on macOS via ioreg".to_string(), + }) + } - let mut reasons = Vec::new(); - - for class_name in GPU_CLASSES { - match std::process::Command::new("ioreg") - .args(["-l", "-w", "0", "-r", "-c", class_name, "-d", "1"]) - .output() - { - Ok(output) if output.status.success() => { - let stdout = String::from_utf8_lossy(&output.stdout); - if let Some(metrics) = parse_ioreg_output(&stdout, class_name) { - return Ok(metrics); + #[cfg(target_os = "macos")] + { + let mut reasons = Vec::new(); + + for class_name in GPU_CLASSES { + match std::process::Command::new("ioreg") + .args(["-l", "-w", "0", "-r", "-c", class_name, "-d", "1"]) + .output() + { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(metrics) = parse_ioreg_output(&stdout, class_name) { + return Ok(metrics); + } + reasons.push(format!("{class_name}: telemetry keys not found")); } - reasons.push(format!("{class_name}: telemetry keys not found")); - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if stderr.is_empty() { - reasons.push(format!("{class_name}: ioreg exited with {}", output.status)); - } else { - reasons.push(format!("{class_name}: {stderr}")); + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + reasons.push(format!("{class_name}: ioreg exited with {}", output.status)); + } else { + reasons.push(format!("{class_name}: {stderr}")); + } + } + Err(error) => { + reasons.push(format!("{class_name}: {error}")); } - } - Err(error) => { - reasons.push(format!("{class_name}: {error}")); } } - } - Err(GpuStatus::Unavailable { - reason: reasons.join(" | "), - }) + Err(GpuStatus::Unavailable { + reason: reasons.join(" | "), + }) + } } +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] fn parse_ioreg_output(output: &str, class_name: &str) -> Option { let device_util = extract_ioreg_value(output, "Device Utilization %") .or_else(|| extract_ioreg_value(output, "Renderer Utilization %"))?; @@ -198,6 +205,7 @@ fn parse_ioreg_output(output: &str, class_name: &str) -> Option { }) } +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] fn extract_ioreg_value(output: &str, key: &str) -> Option { let quoted_key = format!("\"{key}\""); let key_index = output.find("ed_key)?; diff --git a/src/tui/ui.rs b/crates/rust-memex/src/tui/ui.rs similarity index 79% rename from src/tui/ui.rs rename to crates/rust-memex/src/tui/ui.rs index 9e6a7d0..259de1f 100644 --- a/src/tui/ui.rs +++ b/crates/rust-memex/src/tui/ui.rs @@ -829,19 +829,16 @@ fn render_indexing_dashboard( telemetry: Option<&IndexTelemetrySnapshot>, monitor: Option<&MonitorSnapshot>, ) { - let outer = Layout::default() + let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(10), Constraint::Length(4)]) + .constraints([ + Constraint::Length(3), // Progress Gauge + Constraint::Min(10), // Main Stats + Constraint::Length(5), // Recent Warnings + ]) .split(area); - let main = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) - .split(outer[0]); - let left = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(4), Constraint::Min(6)]) - .split(main[0]); + // 1. Top Progress Gauge let ratio = telemetry .map(|snapshot| { if snapshot.total == 0 { @@ -854,123 +851,241 @@ fn render_indexing_dashboard( .clamp(0.0, 1.0); let progress_label = telemetry - .map(|snapshot| format!("{}/{}", snapshot.processed, snapshot.total)) - .unwrap_or_else(|| "waiting".to_string()); + .map(|snapshot| { + format!( + "{}% ({}/{})", + (ratio * 100.0) as u64, + snapshot.processed, + snapshot.total + ) + }) + .unwrap_or_else(|| "0% (waiting)".to_string()); + let progress_gauge = Gauge::default() .block( Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .title(" Index Progress "), + .borders(Borders::BOTTOM) + .padding(Padding::new(1, 1, 0, 0)), ) .gauge_style(Style::default().fg(Color::Cyan).bg(Color::DarkGray)) .ratio(ratio) .label(progress_label); - frame.render_widget(progress_gauge, left[0]); + frame.render_widget(progress_gauge, chunks[0]); + + // 2. Middle section: Left (Operator Stats) | Right (System Telemetry) + let main_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[1]); + // Left: Operator Stats let left_lines = if let Some(snapshot) = telemetry { + let status_style = if snapshot.complete { + Style::default().fg(Color::Green).bold() + } else if snapshot.stopping { + Style::default().fg(Color::Red).bold() + } else if snapshot.paused { + Style::default().fg(Color::Yellow).bold() + } else { + Style::default().fg(Color::Cyan).bold() + }; + let status = if snapshot.complete { - "complete" + "COMPLETE" } else if snapshot.stopping { - "stopping" + "STOPPING" } else if snapshot.paused { - "paused" + "PAUSED" } else { - "running" + "RUNNING" }; + vec![ - Line::from(format!("Rate: {:.2} files/sec", snapshot.files_per_sec)), - Line::from(format!("ETA: {}", format_eta(snapshot.eta_secs))), - Line::from(format!("State: {}", status)), - Line::from(format!( - "Parallelism: {} | Inflight: {}", - snapshot.parallelism, snapshot.in_flight - )), - Line::from(format!( - "Indexed: {} | Skipped: {} | Failed: {}", - snapshot.indexed, snapshot.skipped, snapshot.failed - )), - Line::from(format!("Current file: {}", current_file_label(snapshot))), - ] - } else { - vec![ - Line::from("Waiting for indexing telemetry..."), + Line::from(vec![ + Span::raw("Status: "), + Span::styled(status, status_style), + ]), + Line::from(vec![ + Span::raw("Rate: "), + Span::styled( + format!("{:.2} files/sec", snapshot.files_per_sec), + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::raw("ETA: "), + Span::styled( + format_eta(snapshot.eta_secs), + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::raw("Parallelism: "), + Span::styled( + snapshot.parallelism.to_string(), + Style::default().fg(Color::Yellow), + ), + Span::raw(" (inflight: "), + Span::styled( + snapshot.in_flight.to_string(), + Style::default().fg(Color::DarkGray), + ), + Span::raw(")"), + ]), + Line::from(vec![ + Span::raw("Processed: "), + Span::styled( + snapshot.indexed.to_string(), + Style::default().fg(Color::Green), + ), + Span::raw(" indexed, "), + Span::styled( + snapshot.skipped.to_string(), + Style::default().fg(Color::Yellow), + ), + Span::raw(" skipped, "), + Span::styled(snapshot.failed.to_string(), Style::default().fg(Color::Red)), + Span::raw(" failed"), + ]), + Line::from(vec![ + Span::raw("Chunks: "), + Span::styled( + snapshot.total_chunks.to_string(), + Style::default().fg(Color::Magenta), + ), + Span::raw(" produced"), + ]), Line::from(""), - Line::from("The scheduler will publish live stats here."), + Line::from(vec![ + Span::styled("Current File: ", Style::default().bold()), + Span::styled( + current_file_label(snapshot), + Style::default().fg(Color::DarkGray), + ), + ]), ] + } else { + vec![Line::from("Waiting for indexing telemetry...")] }; - let left_stats = Paragraph::new(left_lines) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .title(" Operator Stats "), - ) - .wrap(Wrap { trim: false }); - frame.render_widget(left_stats, left[1]); - let right_lines = if let Some(snapshot) = monitor { - let gpu_status = match &snapshot.gpu_status { - GpuStatus::Available { class_name } => format!("available ({class_name})"), - GpuStatus::Unavailable { reason } => format!("unavailable ({reason})"), + let operator_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title(" Operator Dashboard "); + frame.render_widget( + Paragraph::new(left_lines).block(operator_block), + main_chunks[0], + ); + + // Right: System Telemetry with Gauges + let system_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title(" System Telemetry "); + + let sys_area = system_block.inner(main_chunks[1]); + frame.render_widget(system_block, main_chunks[1]); + + if let Some(snapshot) = monitor { + let sys_rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), // CPU Gauge + Constraint::Length(2), // RAM Gauge + Constraint::Length(2), // GPU Gauge (if available) + Constraint::Min(4), // Process stats + ]) + .margin(1) + .split(sys_area); + + // CPU Gauge + let cpu_gauge = Gauge::default() + .gauge_style(Style::default().fg(Color::Green).bg(Color::DarkGray)) + .ratio((snapshot.system_cpu_percent / 100.0).clamp(0.0, 1.0) as f64) + .label(format!("CPU: {:.1}%", snapshot.system_cpu_percent)); + frame.render_widget(cpu_gauge, sys_rows[0]); + + // RAM Gauge + let ram_ratio = if snapshot.system_ram_total > 0 { + snapshot.system_ram_used as f64 / snapshot.system_ram_total as f64 + } else { + 0.0 }; - vec![ - Line::from(format!("System CPU: {:.1}%", snapshot.system_cpu_percent)), - Line::from(format!( - "System RAM: {} / {}", + let ram_gauge = Gauge::default() + .gauge_style(Style::default().fg(Color::Blue).bg(Color::DarkGray)) + .ratio(ram_ratio.clamp(0.0, 1.0)) + .label(format!( + "RAM: {} / {}", MonitorSnapshot::format_bytes(snapshot.system_ram_used), MonitorSnapshot::format_bytes(snapshot.system_ram_total) - )), - Line::from(format!("rust-memex CPU: {:.1}%", snapshot.rust_memex_cpu)), - Line::from(format!( - "rust-memex RSS: {}", - MonitorSnapshot::format_bytes(snapshot.rust_memex_rss) - )), - Line::from(format!( - "Embedder CPU: {:.1}%", - snapshot.embedder_cpu_aggregate - )), - Line::from(format!( - "Embedder RSS: {}", - MonitorSnapshot::format_bytes(snapshot.embedder_rss_aggregate) - )), - Line::from(format!( - "GPU util: {}", - snapshot - .gpu_util_percent - .map(|value| format!("{value:.1}%")) - .unwrap_or_else(|| "--".to_string()) - )), - Line::from(format!( - "GPU memory: {} / {}", - snapshot - .gpu_memory_used - .map(MonitorSnapshot::format_bytes) - .unwrap_or_else(|| "--".to_string()), - snapshot - .gpu_memory_total - .map(MonitorSnapshot::format_bytes) - .unwrap_or_else(|| "--".to_string()) - )), - Line::from(format!("GPU status: {gpu_status}")), - ] + )); + frame.render_widget(ram_gauge, sys_rows[1]); + + // GPU Gauge (if available) + if let Some(gpu_util) = snapshot.gpu_util_percent { + let gpu_gauge = Gauge::default() + .gauge_style(Style::default().fg(Color::Magenta).bg(Color::DarkGray)) + .ratio((gpu_util / 100.0).clamp(0.0, 1.0) as f64) + .label(format!("GPU: {:.1}%", gpu_util)); + frame.render_widget(gpu_gauge, sys_rows[2]); + } else { + let gpu_note = match &snapshot.gpu_status { + GpuStatus::Unavailable { reason } => format!("GPU Unavailable: {}", reason), + _ => "GPU: --".to_string(), + }; + frame.render_widget( + Paragraph::new(gpu_note).style(Style::default().fg(Color::DarkGray)), + sys_rows[2], + ); + } + + // Process stats + let process_lines = vec![ + Line::from(vec![ + Span::styled("rust-memex: ", Style::default().bold()), + Span::styled( + format!("{:.1}% CPU", snapshot.rust_memex_cpu), + Style::default().fg(Color::Green), + ), + Span::raw(" | "), + Span::styled( + MonitorSnapshot::format_bytes(snapshot.rust_memex_rss), + Style::default().fg(Color::Blue), + ), + ]), + Line::from(vec![ + Span::styled("Embedder: ", Style::default().bold()), + Span::styled( + format!("{:.1}% CPU", snapshot.embedder_cpu_aggregate), + Style::default().fg(Color::Green), + ), + Span::raw(" | "), + Span::styled( + MonitorSnapshot::format_bytes(snapshot.embedder_rss_aggregate), + Style::default().fg(Color::Blue), + ), + ]), + Line::from(vec![ + Span::styled("GPU VRAM: ", Style::default().bold()), + Span::raw(format!( + "{} / {}", + snapshot + .gpu_memory_used + .map(MonitorSnapshot::format_bytes) + .unwrap_or_else(|| "--".to_string()), + snapshot + .gpu_memory_total + .map(MonitorSnapshot::format_bytes) + .unwrap_or_else(|| "--".to_string()) + )), + ]), + ]; + frame.render_widget(Paragraph::new(process_lines), sys_rows[3]); } else { - vec![ - Line::from("Waiting for system telemetry..."), - Line::from(""), - Line::from("CPU, RAM, process, and GPU stats will stream here."), - ] - }; - let right_stats = Paragraph::new(right_lines) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .title(" System Telemetry "), - ) - .wrap(Wrap { trim: false }); - frame.render_widget(right_stats, main[1]); + frame.render_widget(Paragraph::new("Waiting for system monitor..."), sys_area); + } + // 3. Bottom: Warnings/Log let warning_lines = if let Some(snapshot) = telemetry { let lines: Vec = snapshot .recent_warnings @@ -981,28 +1096,37 @@ fn render_indexing_dashboard( .into_iter() .rev() .map(|warning| { - Line::from(Span::styled( - format!("[{}] {}", warning.code, warning.message), - Style::default().fg(Color::Yellow), - )) + Line::from(vec![ + Span::styled( + format!("[{}] ", warning.code), + Style::default().fg(Color::Red).bold(), + ), + Span::styled(&warning.message, Style::default().fg(Color::Yellow)), + ]) }) .collect(); if lines.is_empty() { vec![Line::from(Span::styled( - "No warnings.", + "No warnings encountered.", Style::default().fg(Color::DarkGray), ))] } else { lines } } else { - vec![Line::from(Span::styled( - "Waiting for warnings...", - Style::default().fg(Color::DarkGray), - ))] + vec![Line::from("Waiting for telemetry...")] }; - let warnings = Paragraph::new(warning_lines).wrap(Wrap { trim: false }); - frame.render_widget(warnings, outer[1]); + + let warnings_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title(" Recent Warnings "); + frame.render_widget( + Paragraph::new(warning_lines) + .block(warnings_block) + .wrap(Wrap { trim: true }), + chunks[2], + ); } fn current_file_label(snapshot: &IndexTelemetrySnapshot) -> String { diff --git a/crates/rust-memex/tests/cli_index_strict.rs b/crates/rust-memex/tests/cli_index_strict.rs new file mode 100644 index 0000000..98c337e --- /dev/null +++ b/crates/rust-memex/tests/cli_index_strict.rs @@ -0,0 +1,210 @@ +use axum::{Json, Router, extract::State, http::StatusCode, routing::post}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{ + fs, + io::ErrorKind, + path::{Path, PathBuf}, + process::{Command, Output}, +}; +use tempfile::TempDir; +use tokio::{net::TcpListener, task::JoinHandle}; + +const REQUIRED_DIMENSION: usize = 16; + +#[derive(Clone)] +struct FailingEmbeddingState { + dimension: usize, +} + +#[derive(Debug, Deserialize)] +struct EmbeddingRequest { + input: Vec, +} + +#[derive(Debug, Serialize)] +struct EmbeddingResponse { + data: Vec, +} + +#[derive(Debug, Serialize)] +struct EmbeddingData { + embedding: Vec, +} + +struct FailingEmbeddingServer { + base_url: String, + handle: JoinHandle<()>, +} + +impl Drop for FailingEmbeddingServer { + fn drop(&mut self) { + self.handle.abort(); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn index_failure_policy_covers_strict_json_threshold_and_warning_modes() { + let Some(server) = start_failing_embedding_server().await else { + eprintln!("skipping CLI strict policy test: sandbox denied loopback bind"); + return; + }; + + let strict = run_index(&server.base_url, &["--strict"]); + assert!( + !strict.status.success(), + "--strict must exit non-zero when every file fails\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&strict.stdout), + String::from_utf8_lossy(&strict.stderr) + ); + assert!( + String::from_utf8_lossy(&strict.stderr).contains("1/1 files failed"), + "strict stderr should include aggregate failed count, got:\n{}", + String::from_utf8_lossy(&strict.stderr) + ); + + let json_output = run_index(&server.base_url, &["--json"]); + assert!( + json_output.status.success(), + "--json without strict keeps backwards-compatible exit zero\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&json_output.stdout), + String::from_utf8_lossy(&json_output.stderr) + ); + let last_line = String::from_utf8_lossy(&json_output.stdout) + .lines() + .last() + .expect("json summary line") + .to_string(); + let summary: Value = serde_json::from_str(&last_line).expect("summary must be valid JSON"); + assert_eq!(summary["indexed"], 0); + assert_eq!(summary["failed"], 1); + assert_eq!(summary["total"], 1); + assert_eq!(summary["failure_rate"], 1.0); + assert!( + summary["errors"] + .as_array() + .is_some_and(|errors| !errors.is_empty()), + "summary should include the per-file embedding error" + ); + + let threshold = run_index(&server.base_url, &["--max-failure-rate", "0.05"]); + assert!( + !threshold.status.success(), + "--max-failure-rate 0.05 must fail when 100% of files fail\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&threshold.stdout), + String::from_utf8_lossy(&threshold.stderr) + ); + assert!( + String::from_utf8_lossy(&threshold.stderr).contains("1/1 files failed"), + "threshold stderr should include aggregate failed count, got:\n{}", + String::from_utf8_lossy(&threshold.stderr) + ); + + let warning = run_index(&server.base_url, &[]); + assert!( + warning.status.success(), + "default mode should preserve exit zero despite per-file failures\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&warning.stdout), + String::from_utf8_lossy(&warning.stderr) + ); + assert!( + String::from_utf8_lossy(&warning.stderr) + .contains("WARNING: 1/1 files failed to index. See log above."), + "default stderr should warn loudly, got:\n{}", + String::from_utf8_lossy(&warning.stderr) + ); +} + +async fn start_failing_embedding_server() -> Option { + let app = Router::new() + .route("/v1/embeddings", post(mock_embeddings)) + .with_state(FailingEmbeddingState { + dimension: REQUIRED_DIMENSION, + }); + + let listener = match TcpListener::bind("127.0.0.1:0").await { + Ok(listener) => listener, + Err(error) if error.kind() == ErrorKind::PermissionDenied => return None, + Err(error) => panic!("bind mock embedding server: {error}"), + }; + let address = listener.local_addr().expect("read mock address"); + let handle = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("mock embedding server failed"); + }); + + Some(FailingEmbeddingServer { + base_url: format!("http://{}", address), + handle, + }) +} + +async fn mock_embeddings( + State(state): State, + Json(request): Json, +) -> Result, (StatusCode, String)> { + if request.input.len() == 1 && request.input[0] == "dimension probe" { + return Ok(Json(EmbeddingResponse { + data: vec![EmbeddingData { + embedding: vec![0.0; state.dimension], + }], + })); + } + + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "intentional per-file embedding failure".to_string(), + )) +} + +fn run_index(base_url: &str, extra_args: &[&str]) -> Output { + let tmp = TempDir::new().expect("tempdir"); + let corpus = tmp.path().join("corpus"); + fs::create_dir_all(&corpus).expect("create corpus"); + fs::write( + corpus.join("doc.md"), + "# Broken embedding test document\n\nThis document exists solely to trigger an embedding failure.\nThe mock embedding server will return HTTP 500 for this content.\nWe need enough text to pass the 50-char minimum document threshold.\n", + ) + .expect("write sample file"); + let db_path = tmp.path().join("lancedb"); + let config_path = write_config(tmp.path(), base_url).expect("write config"); + + let mut args = vec![ + "--config".to_string(), + config_path.to_string_lossy().to_string(), + "--db-path".to_string(), + db_path.to_string_lossy().to_string(), + "--allowed-paths".to_string(), + tmp.path().to_string_lossy().to_string(), + "index".to_string(), + corpus.to_string_lossy().to_string(), + "--namespace".to_string(), + "cli-index-strict".to_string(), + "--recursive".to_string(), + "--slice-mode".to_string(), + "flat".to_string(), + "--chunker".to_string(), + "flat".to_string(), + "--parallel".to_string(), + "1".to_string(), + ]; + args.extend(extra_args.iter().map(|arg| (*arg).to_string())); + + Command::new(env!("CARGO_BIN_EXE_rust-memex")) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .env("RUST_MEMEX_EMBED_BATCH_MAX_RETRIES", "1") + .env("RUST_MEMEX_EMBED_BATCH_MAX_BACKOFF_SECS", "0") + .args(args) + .output() + .expect("run rust-memex index") +} + +fn write_config(base: &Path, base_url: &str) -> std::io::Result { + let config_path = base.join("mock-embeddings.toml"); + let config = format!( + "[embeddings]\nrequired_dimension = {REQUIRED_DIMENSION}\nmax_batch_chars = 32000\nmax_batch_items = 16\n\n[[embeddings.providers]]\nname = \"failing-test\"\nbase_url = \"{base_url}\"\nmodel = \"mock-embedder\"\npriority = 1\nendpoint = \"/v1/embeddings\"\n" + ); + fs::write(&config_path, config)?; + Ok(config_path) +} diff --git a/crates/rust-memex/tests/common/mod.rs b/crates/rust-memex/tests/common/mod.rs new file mode 100644 index 0000000..79f51f8 --- /dev/null +++ b/crates/rust-memex/tests/common/mod.rs @@ -0,0 +1,106 @@ +//! Shared helpers for e2e integration tests. +//! +//! Tests load the operator's canonical `~/.rmcp-servers/rust-memex/config.toml` +//! (or a path from `RUST_MEMEX_CONFIG`) and use its real provider cascade. +//! There is no synthetic fallback — e2e must exercise the runtime config path +//! end-to-end. If no config exists, tests fail-fast with an operator-actionable +//! message. + +#![allow(dead_code)] // helpers are only used under `--features e2e-ollama` + +use anyhow::{Context, Result, anyhow}; +use rust_memex::EmbeddingConfig; +use serde::Deserialize; +use std::path::PathBuf; + +/// Mirrors `bin/cli/config.rs::CONFIG_SEARCH_PATHS`. Keep in sync. +const CONFIG_SEARCH_PATHS: &[&str] = &[ + "~/.rmcp-servers/rust-memex/config.toml", + "~/.config/rust-memex/config.toml", +]; + +/// Minimal mirror of `FileConfig` — only fields e2e tests need. Avoids +/// pulling the binary's `FileConfig` (which is `bin/cli/config.rs`-private) +/// into integration tests. +#[derive(Debug, Deserialize)] +struct FileConfigShim { + #[serde(default)] + db_path: Option, + #[serde(default)] + embeddings: Option, +} + +#[derive(Debug, Clone)] +pub struct E2eConfig { + pub source_path: PathBuf, + pub db_path: Option, + pub embeddings: EmbeddingConfig, +} + +/// Load operator's canonical rust-memex config. +/// +/// Resolution order (mirrors runtime `discover_config`): +/// 1. `RUST_MEMEX_CONFIG` env var +/// 2. CONFIG_SEARCH_PATHS standard locations +/// +/// Returns `Err` (not `Ok` with synthetic defaults) when nothing exists. The +/// e2e contract is "exercise the real config path"; a synthetic default would +/// silently mask broken config discovery. +pub fn load_e2e_config() -> Result { + let path = resolve_config_path().ok_or_else(|| { + anyhow!( + "e2e tests need a real rust-memex config.toml. Set RUST_MEMEX_CONFIG \ + or place one at ~/.rmcp-servers/rust-memex/config.toml. Searched: \ + RUST_MEMEX_CONFIG env, {:?}", + CONFIG_SEARCH_PATHS + ) + })?; + let contents = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read e2e config at {}", path.display()))?; + let shim: FileConfigShim = toml::from_str(&contents) + .with_context(|| format!("Failed to parse e2e config at {}", path.display()))?; + let embeddings = shim.embeddings.ok_or_else(|| { + anyhow!( + "Config at {} has no [embeddings] section — e2e tests need a real \ + provider cascade.", + path.display() + ) + })?; + if embeddings.providers.is_empty() { + return Err(anyhow!( + "Config at {} declares [embeddings] without any providers — \ + e2e tests need at least one [[embeddings.providers]].", + path.display() + )); + } + Ok(E2eConfig { + source_path: path, + db_path: shim.db_path, + embeddings, + }) +} + +fn resolve_config_path() -> Option { + if let Ok(p) = std::env::var("RUST_MEMEX_CONFIG") { + let pb = expand_tilde(&p); + if pb.exists() { + return Some(pb); + } + } + for raw in CONFIG_SEARCH_PATHS { + let pb = expand_tilde(raw); + if pb.exists() { + return Some(pb); + } + } + None +} + +fn expand_tilde(s: &str) -> PathBuf { + if let Some(rest) = s.strip_prefix("~/") + && let Ok(home) = std::env::var("HOME") + { + return PathBuf::from(home).join(rest); + } + PathBuf::from(s) +} diff --git a/crates/rust-memex/tests/config_deny_unknown.rs b/crates/rust-memex/tests/config_deny_unknown.rs new file mode 100644 index 0000000..dab0bbb --- /dev/null +++ b/crates/rust-memex/tests/config_deny_unknown.rs @@ -0,0 +1,110 @@ +use std::{fs, process::Command}; + +use tempfile::TempDir; + +fn write_config(tmp: &TempDir, name: &str, contents: &str) -> std::path::PathBuf { + let path = tmp.path().join(name); + fs::write(&path, contents).expect("write config"); + path +} + +fn run_health_with_config( + config_path: &std::path::Path, + home: &std::path::Path, +) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_rust-memex")) + .env("HOME", home) + .arg("--config") + .arg(config_path) + .args(["health", "--quick", "--json"]) + .output() + .expect("run rust-memex health") +} + +#[test] +fn db_path_inside_embeddings_section_is_rejected() { + let tmp = TempDir::new().expect("tmp"); + let config = write_config( + &tmp, + "misnested-db-path.toml", + r#" +[embeddings] +required_dimension = 4096 +db_path = "/tmp/should-not-be-read" +"#, + ); + + let output = run_health_with_config(&config, tmp.path()); + + assert!( + !output.status.success(), + "misnested db_path must fail loudly" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("unknown field"), "{stderr}"); + assert!(stderr.contains("db_path"), "{stderr}"); +} + +#[test] +fn missing_top_level_db_path_uses_default_and_warns() { + let tmp = TempDir::new().expect("tmp"); + let config = write_config( + &tmp, + "default-db-path.toml", + r#" +[embeddings] +required_dimension = 4096 +max_batch_chars = 32000 +max_batch_items = 16 +"#, + ); + + let output = run_health_with_config(&config, tmp.path()); + + assert!(output.status.success(), "health failed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("WARN: config db_path not specified"), + "{stderr}" + ); + assert!( + stderr.contains(".rmcp-servers/rust-memex/lancedb"), + "{stderr}" + ); + assert!( + stderr.contains("before any section such as [embeddings]"), + "{stderr}" + ); +} + +#[test] +fn top_level_db_path_is_used_without_default_warning() { + let tmp = TempDir::new().expect("tmp"); + let db_path = tmp.path().join("custom-lancedb"); + let config = write_config( + &tmp, + "top-level-db-path.toml", + &format!( + r#" +db_path = "{}" + +[embeddings] +required_dimension = 4096 +max_batch_chars = 32000 +max_batch_items = 16 +"#, + db_path.display() + ), + ); + + let output = run_health_with_config(&config, tmp.path()); + + assert!(output.status.success(), "health failed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("WARN: config db_path not specified"), + "{stderr}" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains(&db_path.display().to_string()), "{stdout}"); +} diff --git a/tests/e2e_cli_folder_index.rs b/crates/rust-memex/tests/e2e_cli_folder_index.rs similarity index 100% rename from tests/e2e_cli_folder_index.rs rename to crates/rust-memex/tests/e2e_cli_folder_index.rs diff --git a/crates/rust-memex/tests/e2e_pipeline.rs b/crates/rust-memex/tests/e2e_pipeline.rs new file mode 100644 index 0000000..373f49b --- /dev/null +++ b/crates/rust-memex/tests/e2e_pipeline.rs @@ -0,0 +1,396 @@ +//! End-to-End Pipeline Tests +//! +//! Full lifecycle verification: load operator's canonical config.toml → +//! build embedder via real provider cascade → index → embed → store → +//! search → delete → confirm-removed → re-add → confirm-restored. +//! +//! These tests are gated behind the `e2e-ollama` feature. They consume the +//! same `~/.rmcp-servers/rust-memex/config.toml` (or `RUST_MEMEX_CONFIG`) +//! that runtime uses — no synthetic defaults, no inline hardcoded endpoints. +//! If no config exists or no provider in the cascade is reachable, tests +//! fail-fast (no silent SKIP). +//! +//! Run with: `make test-e2e` +//! or `cargo test --features e2e-ollama --test e2e_pipeline` +//! +//! Vibecrafted with AI Agents by VetCoders (c)2024-2026 LibraxisAI + +mod common; + +use rust_memex::{ChromaDocument, StorageManager}; +use serde_json::json; +use tempfile::TempDir; + +/// Synthetic dimension for storage-validation tests that don't touch a real +/// provider. Storage rules don't care about the value, only consistency. +const SYNTHETIC_TEST_DIM: usize = 2560; + +// ============================================================================= +// E2E TEST: Full pipeline lifecycle (index → search → delete → restore) +// ============================================================================= + +/// Full lifecycle: index 5 docs, semantic search, delete one, confirm absent, +/// re-add, confirm restored. Exercises the config-driven provider cascade +/// for embedding + LanceDB storage end-to-end. +#[cfg(feature = "e2e-ollama")] +#[tokio::test] +async fn test_e2e_full_lifecycle_index_search_delete_restore() { + use rust_memex::EmbeddingClient; + + let cfg = common::load_e2e_config().expect("e2e config required"); + eprintln!( + "e2e config: {} (providers: {})", + cfg.source_path.display(), + cfg.embeddings.providers.len() + ); + + let mut embedder = EmbeddingClient::new(&cfg.embeddings) + .await + .expect("EmbeddingClient must connect to a configured provider"); + let dim = cfg.embeddings.required_dimension; + let connected = embedder.connected_to().to_string(); + eprintln!("connected provider: {connected}"); + + let tmp = TempDir::new().expect("tempdir"); + let db_path = tmp.path().join("lancedb"); + let storage = StorageManager::new_lance_only(db_path.to_str().unwrap()) + .await + .expect("storage init"); + storage + .ensure_collection() + .await + .expect("ensure_collection"); + + let ns = "e2e-lifecycle"; + + // Test corpus + let corpus = [ + ( + "doc-rust", + "Rust is a systems programming language focused on safety and performance.", + ), + ( + "doc-python", + "Python is a high-level interpreted language known for its readability.", + ), + ( + "doc-javascript", + "JavaScript is the language of the web, running in browsers and Node.js.", + ), + ( + "doc-veterinary", + "Veterinary medicine involves the diagnosis and treatment of animals.", + ), + ( + "doc-ai", + "Artificial intelligence enables machines to learn from experience.", + ), + ]; + + // ---- INDEX -------------------------------------------------------------- + let mut docs = Vec::with_capacity(corpus.len()); + for (id, text) in corpus.iter() { + let embedding = embedder.embed(text).await.expect("embed"); + assert_eq!(embedding.len(), dim, "embedding dim must match config"); + docs.push(ChromaDocument::new_flat( + (*id).to_string(), + ns.to_string(), + embedding, + json!({"source": "e2e"}), + (*text).to_string(), + )); + } + storage.add_to_store(docs).await.expect("add_to_store"); + + // ---- SEARCH (initial) --------------------------------------------------- + let query = "systems programming language with memory safety"; + let q_embedding = embedder.embed(query).await.expect("embed query"); + let results = storage + .search_store(Some(ns), q_embedding.clone(), corpus.len()) + .await + .expect("search_store initial"); + assert!(!results.is_empty(), "search must return results"); + let initial_ids: Vec = results.iter().map(|d| d.id.clone()).collect(); + eprintln!("initial search top-{}: {:?}", results.len(), initial_ids); + assert!( + initial_ids.iter().any(|id| id == "doc-rust"), + "doc-rust must be reachable before delete; got {:?}", + initial_ids + ); + + // ---- DELETE ------------------------------------------------------------- + let removed = storage + .delete_documents(ns, &["doc-rust"]) + .await + .expect("delete_documents"); + assert_eq!(removed, 1, "delete must remove exactly 1 document"); + + // ---- SEARCH (after delete) --------------------------------------------- + let results_after_delete = storage + .search_store(Some(ns), q_embedding.clone(), corpus.len()) + .await + .expect("search_store after delete"); + let after_delete_ids: Vec = results_after_delete.iter().map(|d| d.id.clone()).collect(); + eprintln!( + "after-delete search top-{}: {:?}", + results_after_delete.len(), + after_delete_ids + ); + assert!( + !after_delete_ids.iter().any(|id| id == "doc-rust"), + "doc-rust must be absent after delete; got {:?}", + after_delete_ids + ); + + // ---- RESTORE ------------------------------------------------------------ + let rust_text = corpus + .iter() + .find(|(id, _)| *id == "doc-rust") + .map(|(_, t)| *t) + .expect("doc-rust in corpus"); + let restore_embedding = embedder.embed(rust_text).await.expect("re-embed"); + storage + .add_to_store(vec![ChromaDocument::new_flat( + "doc-rust".to_string(), + ns.to_string(), + restore_embedding, + json!({"source": "e2e", "restored": true}), + rust_text.to_string(), + )]) + .await + .expect("re-add doc-rust"); + + let results_after_restore = storage + .search_store(Some(ns), q_embedding, corpus.len()) + .await + .expect("search_store after restore"); + let after_restore_ids: Vec = + results_after_restore.iter().map(|d| d.id.clone()).collect(); + eprintln!( + "after-restore search top-{}: {:?}", + results_after_restore.len(), + after_restore_ids + ); + assert!( + after_restore_ids.iter().any(|id| id == "doc-rust"), + "doc-rust must reappear after restore; got {:?}", + after_restore_ids + ); +} + +/// Batch embedding sanity: embed multiple texts in one call, verify dim +/// consistency and absence of NaN/Inf in every row. +#[cfg(feature = "e2e-ollama")] +#[tokio::test] +async fn test_e2e_batch_embedding() { + use rust_memex::EmbeddingClient; + + let cfg = common::load_e2e_config().expect("e2e config required"); + let mut embedder = EmbeddingClient::new(&cfg.embeddings) + .await + .expect("EmbeddingClient must connect"); + let dim = cfg.embeddings.required_dimension; + + let texts: Vec = vec![ + "First document about machine learning".into(), + "Second document about natural language processing".into(), + "Third document about computer vision".into(), + "Fourth document about reinforcement learning".into(), + ]; + + let embeddings = embedder.embed_batch(&texts).await.expect("embed_batch"); + assert_eq!( + embeddings.len(), + texts.len(), + "must return one embedding per input" + ); + for (i, emb) in embeddings.iter().enumerate() { + assert_eq!(emb.len(), dim, "embedding {i} dim mismatch"); + for (j, &val) in emb.iter().enumerate() { + assert!( + !val.is_nan() && !val.is_infinite(), + "embedding {i}[{j}] is NaN/Inf: {val}" + ); + } + } +} + +/// Dimension-mismatch fail-fast: bumping required_dimension above what the +/// provider returns must error at client construction (before any DB write). +/// Exercises the EmbeddingClient invariant: dim mismatch corrupts the DB if +/// it slips through, so it must abort early. +#[cfg(feature = "e2e-ollama")] +#[tokio::test] +async fn test_e2e_dim_mismatch_fails_fast() { + use rust_memex::EmbeddingClient; + + let cfg = common::load_e2e_config().expect("e2e config required"); + // Sanity: real config must work first + let _ok = EmbeddingClient::new(&cfg.embeddings) + .await + .expect("baseline config must succeed"); + + // Bump dim by 1 — no real model returns this; client must reject. + let mut bad = cfg.embeddings.clone(); + bad.required_dimension += 1; + let err = EmbeddingClient::new(&bad) + .await + .err() + .expect("dim mismatch must error at construction"); + let msg = err.to_string(); + assert!( + msg.contains("required_dimension") || msg.contains("dim"), + "error must mention dimension; got: {msg}" + ); +} + +/// Content-hash deduplication: store doc with hash, observe presence; ensure +/// hash-check API stays consistent across roundtrip. +#[cfg(feature = "e2e-ollama")] +#[tokio::test] +async fn test_e2e_deduplication() { + use rust_memex::{EmbeddingClient, compute_content_hash}; + + let cfg = common::load_e2e_config().expect("e2e config required"); + let mut embedder = EmbeddingClient::new(&cfg.embeddings) + .await + .expect("EmbeddingClient must connect"); + + let tmp = TempDir::new().expect("tempdir"); + let db_path = tmp.path().join("lancedb"); + let storage = StorageManager::new_lance_only(db_path.to_str().unwrap()) + .await + .expect("storage init"); + storage + .ensure_collection() + .await + .expect("ensure_collection"); + + let ns = "e2e-dedup"; + let content = "Unique content for deduplication test"; + let hash = compute_content_hash(content); + + assert!( + !storage + .has_content_hash(ns, &hash) + .await + .expect("has_content_hash"), + "hash must not exist before indexing" + ); + + let embedding = embedder.embed(content).await.expect("embed"); + let mut doc = ChromaDocument::new_flat( + "dedup-doc-1".to_string(), + ns.to_string(), + embedding, + json!({}), + content.to_string(), + ); + doc.content_hash = Some(hash.clone()); + storage.add_to_store(vec![doc]).await.expect("add_to_store"); + + assert!( + storage + .has_content_hash(ns, &hash) + .await + .expect("has_content_hash post"), + "hash must exist after indexing" + ); +} + +// ============================================================================= +// STORAGE INVARIANT (no embedding provider needed) +// ============================================================================= + +/// Storage-level validation: empty IDs, empty namespaces, NaN/Inf in +/// embeddings, and inconsistent batch dimensions must all be rejected before +/// any LanceDB write. This invariant doesn't need a real provider — it +/// exercises pure storage rules with synthetic dim. +#[tokio::test] +async fn test_storage_rejects_invalid_embeddings() { + let tmp = TempDir::new().expect("tempdir"); + let db_path = tmp.path().join("lancedb"); + let storage = StorageManager::new_lance_only(db_path.to_str().unwrap()) + .await + .expect("storage init"); + storage.ensure_collection().await.unwrap(); + + // Empty ID rejected + let doc_empty_id = ChromaDocument::new_flat( + String::new(), + "test-ns".to_string(), + vec![0.1f32; SYNTHETIC_TEST_DIM], + json!({}), + "Content".to_string(), + ); + assert!( + storage.add_to_store(vec![doc_empty_id]).await.is_err(), + "Empty ID must be rejected" + ); + + // Empty namespace rejected + let doc_empty_ns = ChromaDocument::new_flat( + "valid-id".to_string(), + String::new(), + vec![0.1f32; SYNTHETIC_TEST_DIM], + json!({}), + "Content".to_string(), + ); + assert!( + storage.add_to_store(vec![doc_empty_ns]).await.is_err(), + "Empty namespace must be rejected" + ); + + // NaN in embedding rejected + let mut nan_emb = vec![0.1f32; SYNTHETIC_TEST_DIM]; + nan_emb[100] = f32::NAN; + let doc_nan = ChromaDocument::new_flat( + "nan-doc".to_string(), + "test-ns".to_string(), + nan_emb, + json!({}), + "Content".to_string(), + ); + assert!( + storage.add_to_store(vec![doc_nan]).await.is_err(), + "NaN in embedding must be rejected" + ); + + // Inf in embedding rejected + let mut inf_emb = vec![0.1f32; SYNTHETIC_TEST_DIM]; + inf_emb[200] = f32::INFINITY; + let doc_inf = ChromaDocument::new_flat( + "inf-doc".to_string(), + "test-ns".to_string(), + inf_emb, + json!({}), + "Content".to_string(), + ); + assert!( + storage.add_to_store(vec![doc_inf]).await.is_err(), + "Inf in embedding must be rejected" + ); + + // Inconsistent dims in same batch rejected + let doc_good = ChromaDocument::new_flat( + "doc-good".to_string(), + "test-ns".to_string(), + vec![0.1f32; SYNTHETIC_TEST_DIM], + json!({}), + "Content".to_string(), + ); + let doc_short = ChromaDocument::new_flat( + "doc-short".to_string(), + "test-ns".to_string(), + vec![0.1f32; 1024], // wrong dim within batch + json!({}), + "Content".to_string(), + ); + assert!( + storage + .add_to_store(vec![doc_good, doc_short]) + .await + .is_err(), + "Inconsistent batch dims must be rejected" + ); +} diff --git a/tests/engine_integration.rs b/crates/rust-memex/tests/engine_integration.rs similarity index 100% rename from tests/engine_integration.rs rename to crates/rust-memex/tests/engine_integration.rs diff --git a/tests/fixtures/ioreg_m3_ultra.txt b/crates/rust-memex/tests/fixtures/ioreg_m3_ultra.txt similarity index 100% rename from tests/fixtures/ioreg_m3_ultra.txt rename to crates/rust-memex/tests/fixtures/ioreg_m3_ultra.txt diff --git a/crates/rust-memex/tests/health_schema.rs b/crates/rust-memex/tests/health_schema.rs new file mode 100644 index 0000000..48b7fc3 --- /dev/null +++ b/crates/rust-memex/tests/health_schema.rs @@ -0,0 +1,306 @@ +use std::sync::Arc; + +use arrow_schema::{DataType, Field, Schema}; +use axum::{ + Json, Router, + body::{Body, to_bytes}, + extract::State, + http::{Method, Request, StatusCode, header}, + routing::post, +}; +use lancedb::connect; +use rust_memex::{ + AuthManager, DEFAULT_TABLE_NAME, EmbeddingClient, EmbeddingConfig, McpCore, ProviderConfig, + RAGPipeline, SchemaVersion, StorageManager, + http::{HttpServerConfig, HttpState, create_router}, +}; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::{net::TcpListener, sync::Mutex, task::JoinHandle}; +use tower::util::ServiceExt; + +const AUTH_TOKEN: &str = "secret-token"; +const TEST_DIMENSION: usize = 8; + +struct TestApp { + app: Router, + db_path: String, + storage: Arc, + _tmp: TempDir, + _mock_server: MockEmbeddingServer, +} + +#[derive(Clone)] +struct MockEmbeddingState { + dimension: usize, +} + +#[derive(Debug, serde::Deserialize)] +struct MockEmbeddingRequest { + input: Vec, +} + +#[derive(Debug, serde::Serialize)] +struct MockEmbeddingResponse { + data: Vec, +} + +#[derive(Debug, serde::Serialize)] +struct MockEmbeddingData { + embedding: Vec, +} + +struct MockEmbeddingServer { + base_url: String, + handle: JoinHandle<()>, +} + +impl Drop for MockEmbeddingServer { + fn drop(&mut self) { + self.handle.abort(); + } +} + +async fn mock_embeddings( + State(state): State, + Json(request): Json, +) -> Json { + let data = request + .input + .into_iter() + .enumerate() + .map(|(index, _)| MockEmbeddingData { + embedding: vec![index as f32 + 0.25; state.dimension], + }) + .collect(); + Json(MockEmbeddingResponse { data }) +} + +async fn start_mock_embedding_server() -> MockEmbeddingServer { + let app = Router::new() + .route("/v1/embeddings", post(mock_embeddings)) + .with_state(MockEmbeddingState { + dimension: TEST_DIMENSION, + }); + + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind mock"); + let address = listener.local_addr().expect("mock address"); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.expect("mock server"); + }); + + MockEmbeddingServer { + base_url: format!("http://{}", address), + handle, + } +} + +fn test_embedding_config(base_url: &str) -> EmbeddingConfig { + EmbeddingConfig { + required_dimension: TEST_DIMENSION, + max_batch_chars: 16_000, + max_batch_items: 8, + providers: vec![ProviderConfig { + name: "mock".to_string(), + base_url: base_url.to_string(), + model: "mock-embed".to_string(), + priority: 1, + endpoint: "/v1/embeddings".to_string(), + }], + reranker: Default::default(), + } +} + +async fn build_test_app() -> TestApp { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("lancedb"); + let mock_server = start_mock_embedding_server().await; + let embedding_config = test_embedding_config(&mock_server.base_url); + let embedding_client = Arc::new(Mutex::new( + EmbeddingClient::new(&embedding_config) + .await + .expect("embedding client"), + )); + let storage = Arc::new( + StorageManager::new(db_path.to_str().unwrap()) + .await + .expect("storage"), + ); + let rag = Arc::new( + RAGPipeline::new(embedding_client.clone(), storage.clone()) + .await + .expect("rag"), + ); + + let tokens_path = tmp.path().join("tokens.json"); + let auth_manager = Arc::new(AuthManager::new( + tokens_path.to_string_lossy().to_string(), + None, + )); + let mcp_core = Arc::new(McpCore::new( + rag.clone(), + None, + embedding_client, + 1024 * 1024, + vec![], + auth_manager, + )); + let state = HttpState::new(rag, mcp_core); + let config = HttpServerConfig { + auth_token: Some(AUTH_TOKEN.to_string()), + ..Default::default() + }; + let app = create_router(state, &config); + + TestApp { + app, + db_path: db_path.to_string_lossy().to_string(), + storage, + _tmp: tmp, + _mock_server: mock_server, + } +} + +async fn create_pre_v4_table(db_path: &str) { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Utf8, false), + Field::new("namespace", DataType::Utf8, false), + Field::new( + "vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float32, true)), + TEST_DIMENSION as i32, + ), + false, + ), + Field::new("text", DataType::Utf8, true), + Field::new("metadata", DataType::Utf8, true), + Field::new("layer", DataType::UInt8, true), + Field::new("parent_id", DataType::Utf8, true), + Field::new("children_ids", DataType::Utf8, true), + Field::new("keywords", DataType::Utf8, true), + Field::new("content_hash", DataType::Utf8, true), + ])); + connect(db_path) + .execute() + .await + .expect("connect lancedb") + .create_empty_table(DEFAULT_TABLE_NAME, schema) + .execute() + .await + .expect("create pre-v4 table"); +} + +fn request(method: Method, uri: &str, body: Option) -> Request { + let mut builder = Request::builder().method(method).uri(uri); + if body.is_some() { + builder = builder + .header(header::CONTENT_TYPE, "application/json") + .header(header::AUTHORIZATION, format!("Bearer {AUTH_TOKEN}")); + } + builder + .body(Body::from( + body.map(|value| value.to_string()).unwrap_or_default(), + )) + .expect("request") +} + +async fn read_json(response: axum::response::Response) -> Value { + let body = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("body bytes"); + serde_json::from_slice(&body).expect("json body") +} + +#[tokio::test] +async fn health_reports_pre_v4_table_as_needing_migration() { + let test_app = build_test_app().await; + create_pre_v4_table(&test_app.db_path).await; + + let response = test_app + .app + .oneshot(request(Method::GET, "/health", None)) + .await + .expect("health response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = read_json(response).await; + assert_eq!(body["status"], "needs_migration"); + assert_eq!(body["schema_version"], "v3-pre"); + assert_eq!(body["expected_schema"], "v4"); + assert!(body["needs_migration"].as_bool().unwrap()); + assert_eq!(body["missing_columns"], json!(["source_hash"])); + assert!(body["manifest_version"].as_u64().is_some(), "{body}"); + assert_eq!(body["last_successful_append_at"], Value::Null); +} + +#[tokio::test] +async fn health_reports_ok_after_migration_and_successful_upsert() { + let test_app = build_test_app().await; + create_pre_v4_table(&test_app.db_path).await; + StorageManager::migrate_lance_schema(&test_app.db_path, SchemaVersion::current(), false) + .await + .expect("migrate schema"); + test_app.storage.refresh().await.expect("refresh storage"); + + let upsert = test_app + .app + .clone() + .oneshot(request( + Method::POST, + "/upsert", + Some(json!({ + "namespace": "kb:health", + "id": "doc-1", + "content": "health schema status verifies append freshness", + "metadata": {"slice_mode": "flat"} + })), + )) + .await + .expect("upsert response"); + assert_eq!(upsert.status(), StatusCode::OK); + + let response = test_app + .app + .oneshot(request(Method::GET, "/health", None)) + .await + .expect("health response"); + + let body = read_json(response).await; + assert_eq!(body["status"], "ok"); + assert_eq!(body["schema_version"], "v4"); + assert!(!body["needs_migration"].as_bool().unwrap()); + assert_eq!(body["missing_columns"], json!([])); + assert!( + body["last_successful_append_at"].as_str().is_some(), + "{body}" + ); + assert_eq!(body["namespaces"]["kb:health"]["chunks"], 1); + assert!( + body["namespaces"]["kb:health"]["last_indexed_at"] + .as_str() + .is_some(), + "{body}" + ); +} + +#[tokio::test] +async fn health_reports_empty_current_schema_before_first_append() { + let test_app = build_test_app().await; + + let response = test_app + .app + .oneshot(request(Method::GET, "/health", None)) + .await + .expect("health response"); + + let body = read_json(response).await; + assert_eq!(body["status"], "ok"); + assert_eq!(body["schema_version"], "v4"); + assert_eq!(body["expected_schema"], "v4"); + assert!(!body["needs_migration"].as_bool().unwrap()); + assert_eq!(body["missing_columns"], json!([])); + assert_eq!(body["manifest_version"], Value::Null); + assert_eq!(body["last_successful_append_at"], Value::Null); + assert_eq!(body["namespaces"], json!({})); +} diff --git a/crates/rust-memex/tests/http_diagnostic_endpoints.rs b/crates/rust-memex/tests/http_diagnostic_endpoints.rs new file mode 100644 index 0000000..6175e6e --- /dev/null +++ b/crates/rust-memex/tests/http_diagnostic_endpoints.rs @@ -0,0 +1,646 @@ +use axum::{ + Json, Router, + body::{Body, to_bytes}, + extract::State, + http::{Method, Request, StatusCode, header}, + routing::post, +}; +use rust_memex::{ + AuthManager, ChromaDocument, EmbeddingClient, EmbeddingConfig, McpCore, ProviderConfig, + RAGPipeline, SliceLayer, StorageManager, + contracts::{ + audit::AuditResult, + stats::{DatabaseStats, NamespaceStats}, + timeline::TimelineEntry, + }, + http::{HttpServerConfig, HttpState, create_router}, +}; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::{net::TcpListener, sync::Mutex, task::JoinHandle}; +use tower::util::ServiceExt; + +const AUTH_TOKEN: &str = "secret-token"; +const EMBEDDING_DIMENSION: usize = 2; + +#[derive(Clone)] +struct MockEmbeddingState { + dimension: usize, +} + +#[derive(Debug, serde::Deserialize)] +struct MockEmbeddingRequest { + input: Vec, +} + +#[derive(Debug, serde::Serialize)] +struct MockEmbeddingResponse { + data: Vec, +} + +#[derive(Debug, serde::Serialize)] +struct MockEmbeddingData { + embedding: Vec, +} + +struct MockEmbeddingServer { + base_url: String, + handle: JoinHandle<()>, +} + +impl Drop for MockEmbeddingServer { + fn drop(&mut self) { + self.handle.abort(); + } +} + +struct TestApp { + app: axum::Router, + storage: Arc, + _tmp: TempDir, + _mock_server: MockEmbeddingServer, +} + +async fn mock_embeddings( + State(state): State, + Json(request): Json, +) -> Json { + let data = request + .input + .into_iter() + .enumerate() + .map(|(index, _)| MockEmbeddingData { + embedding: vec![index as f32 + 0.25; state.dimension], + }) + .collect(); + Json(MockEmbeddingResponse { data }) +} + +async fn start_mock_embedding_server() -> MockEmbeddingServer { + let app = Router::new() + .route("/v1/embeddings", post(mock_embeddings)) + .with_state(MockEmbeddingState { + dimension: EMBEDDING_DIMENSION, + }); + + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind mock"); + let address = listener.local_addr().expect("mock address"); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.expect("mock server"); + }); + + MockEmbeddingServer { + base_url: format!("http://{}", address), + handle, + } +} + +fn test_embedding_config(base_url: &str) -> EmbeddingConfig { + EmbeddingConfig { + required_dimension: EMBEDDING_DIMENSION, + max_batch_chars: 16_000, + max_batch_items: 8, + providers: vec![ProviderConfig { + name: "mock".to_string(), + base_url: base_url.to_string(), + model: "mock-embed".to_string(), + priority: 1, + endpoint: "/v1/embeddings".to_string(), + }], + reranker: Default::default(), + } +} + +async fn build_test_app() -> TestApp { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("lancedb"); + let mock_server = start_mock_embedding_server().await; + let embedding_config = test_embedding_config(&mock_server.base_url); + let embedding_client = Arc::new(Mutex::new( + EmbeddingClient::new(&embedding_config) + .await + .expect("embedding client"), + )); + let storage = Arc::new( + StorageManager::new(db_path.to_str().unwrap()) + .await + .expect("storage"), + ); + let rag = Arc::new( + RAGPipeline::new(embedding_client.clone(), storage.clone()) + .await + .expect("rag"), + ); + let tokens_path = tmp.path().join("tokens.json"); + let auth_manager = Arc::new(AuthManager::new( + tokens_path.to_string_lossy().to_string(), + None, + )); + let mcp_core = Arc::new(McpCore::new( + rag.clone(), + None, + embedding_client, + 1024 * 1024, + vec![], + auth_manager, + )); + let state = HttpState::new(rag, mcp_core); + let app = create_router( + state, + &HttpServerConfig { + auth_token: Some(AUTH_TOKEN.to_string()), + ..Default::default() + }, + ); + + seed_documents(storage.as_ref()).await; + + TestApp { + app, + storage, + _tmp: tmp, + _mock_server: mock_server, + } +} + +async fn seed_documents(storage: &StorageManager) { + let docs = vec![ + doc_with_layer_and_hash( + "klaud-outer", + "klaudiusz-memories", + SliceLayer::Outer, + "Structured summary about patient follow-up and medication changes.", + "hash-klaud-outer", + None, + json!({ + "indexed_at": "2026-04-18T09:15:00Z", + "source": "klaudiusz-summary.md" + }), + &["patient", "summary"], + ), + doc_with_layer_and_hash( + "klaud-core", + "klaudiusz-memories", + SliceLayer::Core, + "Detailed clinical note with multiple sentences. The record includes observations, medication timing, and recovery guidance.", + "hash-klaud-core", + None, + json!({ + "indexed_at": "2026-04-18T09:25:00Z", + "source": "klaudiusz-core.md" + }), + &["clinical", "recovery"], + ), + doc_with_layer_and_hash( + "aicx-1", + "aicx", + SliceLayer::Outer, + "Timeline entry for audit day one.", + "hash-aicx-1", + None, + json!({ + "indexed_at": "2026-04-17T10:15:00Z", + "source": "day-one.md" + }), + &["timeline"], + ), + doc_with_layer_and_hash( + "aicx-2", + "aicx", + SliceLayer::Outer, + "Timeline entry for audit day two.", + "hash-aicx-2", + None, + json!({ + "indexed_at": "2026-04-18T11:45:00Z", + "source": "day-two.md" + }), + &["timeline"], + ), + // The two `dup-*` rows share both `content_hash` AND `source_hash` (+ layer) + // so the post-v4 default (`source-hash-layer`) and the legacy + // (`content-hash`) modes both classify them as one duplicate cluster. + // Spec P4: "dedup CLI: nowy default `--group-by source-hash-layer` zachowuje + // onion (1 chunk per layer per source), stary `--group-by content-hash` + // jako opt-in dla edge cases". + doc_with_layer_and_hash( + "dup-keep", + "dup-ns", + SliceLayer::Outer, + "This duplicate content should collapse.", + "dup-hash", + Some("dup-source"), + json!({ + "indexed_at": "2026-04-19T08:00:00Z", + "source": "dup-a.md" + }), + &["dup"], + ), + doc_with_layer_and_hash( + "dup-remove", + "dup-ns", + SliceLayer::Outer, + "This duplicate content should collapse.", + "dup-hash", + Some("dup-source"), + json!({ + "indexed_at": "2026-04-19T08:05:00Z", + "source": "dup-b.md" + }), + &["dup"], + ), + doc_with_layer_and_hash( + "dup-unique", + "dup-ns", + SliceLayer::Outer, + "This entry is unique.", + "dup-unique-hash", + Some("dup-unique-source"), + json!({ + "indexed_at": "2026-04-19T08:10:00Z", + "source": "dup-c.md" + }), + &["unique"], + ), + // Pre-v4 row: `content_hash` populated but `source_hash` is null. Under the + // post-v4 default the row contributes to `docs_without_hash`; under the + // legacy `content-hash` opt-in the same row is a regular candidate. + doc_with_layer_and_hash( + "dup-pre-v4", + "dup-ns", + SliceLayer::Outer, + "Legacy chunk written before source_hash was wired up.", + "pre-v4-hash", + None, + json!({ + "indexed_at": "2026-04-19T08:15:00Z", + "source": "dup-pre-v4.md" + }), + &["legacy"], + ), + doc_with_layer_and_hash( + "purge-1", + "purge-me", + SliceLayer::Outer, + "fragment", + "purge-hash-1", + None, + json!({ + "indexed_at": "2026-04-19T07:00:00Z", + "source": "broken-a.txt" + }), + &["broken"], + ), + doc_with_layer_and_hash( + "purge-2", + "purge-me", + SliceLayer::Outer, + "noise", + "purge-hash-2", + None, + json!({ + "indexed_at": "2026-04-19T07:05:00Z", + "source": "broken-b.txt" + }), + &["broken"], + ), + ]; + + storage.add_to_store(docs).await.expect("seed docs"); +} + +#[allow(clippy::too_many_arguments)] +fn doc_with_layer_and_hash( + id: &str, + namespace: &str, + layer: SliceLayer, + text: &str, + content_hash: &str, + source_hash: Option<&str>, + metadata: Value, + keywords: &[&str], +) -> ChromaDocument { + let mut doc = ChromaDocument::new_flat_with_hashes( + id.to_string(), + namespace.to_string(), + vec![0.25; EMBEDDING_DIMENSION], + metadata, + text.to_string(), + content_hash.to_string(), + source_hash.map(ToString::to_string), + ); + doc.layer = layer.as_u8(); + doc.keywords = keywords + .iter() + .map(|keyword| (*keyword).to_string()) + .collect(); + doc +} + +fn authed_request(method: Method, uri: &str, body: Option) -> Request { + let builder = Request::builder() + .method(method) + .uri(uri) + .header(header::AUTHORIZATION, format!("Bearer {AUTH_TOKEN}")); + + if let Some(json_body) = body { + builder + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(json_body.to_string())) + .expect("authed json request") + } else { + builder.body(Body::empty()).expect("authed empty request") + } +} + +async fn response_json(response: axum::response::Response) -> T { + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("body bytes"); + serde_json::from_slice(&bytes).expect("json body") +} + +#[tokio::test] +async fn audit_endpoint_returns_contract_json() { + let test_app = build_test_app().await; + + let response = test_app + .app + .clone() + .oneshot( + Request::builder() + .uri("/api/audit?ns=klaudiusz-memories&threshold=80") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("audit response"); + + assert_eq!(response.status(), StatusCode::OK); + let audit: AuditResult = response_json(response).await; + assert_eq!(audit.namespace, "klaudiusz-memories"); + assert!(audit.document_count >= 2); +} + +#[tokio::test] +async fn database_stats_endpoint_returns_rows() { + let test_app = build_test_app().await; + + let response = test_app + .app + .clone() + .oneshot( + Request::builder() + .uri("/api/stats") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("stats response"); + + assert_eq!(response.status(), StatusCode::OK); + let stats: DatabaseStats = response_json(response).await; + assert!(stats.row_count >= 9); + assert_eq!(stats.table_name, "mcp_documents"); +} + +#[tokio::test] +async fn namespace_stats_endpoint_returns_layer_distribution() { + let test_app = build_test_app().await; + + let response = test_app + .app + .clone() + .oneshot( + Request::builder() + .uri("/api/stats/klaudiusz-memories") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("namespace stats response"); + + assert_eq!(response.status(), StatusCode::OK); + let stats: NamespaceStats = response_json(response).await; + assert_eq!(stats.name, "klaudiusz-memories"); + assert!(stats.total_chunks >= 2); + assert_eq!(stats.layer_counts.get("outer"), Some(&1)); + assert_eq!(stats.layer_counts.get("core"), Some(&1)); +} + +#[tokio::test] +async fn timeline_endpoint_returns_bucketed_entries() { + let test_app = build_test_app().await; + + let response = test_app + .app + .clone() + .oneshot( + Request::builder() + .uri("/api/timeline?ns=aicx&bucket=day") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("timeline response"); + + assert_eq!(response.status(), StatusCode::OK); + let entries: Vec = response_json(response).await; + assert!(!entries.is_empty()); + assert!(entries.iter().all(|entry| entry.namespace == "aicx")); + assert!(entries.iter().any(|entry| entry.date == "2026-04-17")); + assert!(entries.iter().any(|entry| entry.date == "2026-04-18")); +} + +#[tokio::test] +async fn purge_quality_endpoint_requires_dry_run_then_executes() { + let test_app = build_test_app().await; + + let blocked_response = test_app + .app + .clone() + .oneshot(authed_request( + Method::POST, + "/api/purge-quality", + Some(json!({ + "namespace": "purge-me", + "threshold": 90, + "confirm": true, + "dry_run": false + })), + )) + .await + .expect("blocked purge response"); + + assert_eq!(blocked_response.status(), StatusCode::CONFLICT); + + let dry_run_response = test_app + .app + .clone() + .oneshot(authed_request( + Method::POST, + "/api/purge-quality", + Some(json!({ + "namespace": "purge-me", + "threshold": 90, + "confirm": false, + "dry_run": true + })), + )) + .await + .expect("purge dry run"); + + assert_eq!(dry_run_response.status(), StatusCode::OK); + let dry_run_json: Value = response_json(dry_run_response).await; + assert!(dry_run_json["dry_run"].as_bool().unwrap()); + assert_eq!( + test_app + .storage + .count_namespace("purge-me") + .await + .expect("pre purge count"), + 2 + ); + + let execute_response = test_app + .app + .clone() + .oneshot(authed_request( + Method::POST, + "/api/purge-quality", + Some(json!({ + "namespace": "purge-me", + "threshold": 90, + "confirm": true, + "dry_run": false + })), + )) + .await + .expect("purge execute"); + + assert_eq!(execute_response.status(), StatusCode::OK); + let execute_json: Value = response_json(execute_response).await; + assert_eq!(execute_json["purged_namespaces"], 1); + assert_eq!( + test_app + .storage + .count_namespace("purge-me") + .await + .expect("post purge count"), + 0 + ); +} + +/// Default endpoint behaviour after spec P4 ("dedup grouping") locked +/// `source-hash-layer` as the post-v4 default. The two `dup-*` rows share +/// content_hash AND source_hash AND layer, so the cluster collapses to one +/// group keyed by `|layer`. The pre-v4 row (no source_hash) +/// is reported via `docs_without_hash`, never silently swept under default +/// grouping. Execute deletes exactly one chunk and the surviving namespace +/// keeps every distinct row (kept duplicate + unique + pre-v4). +#[tokio::test] +async fn dedup_endpoint_lists_duplicates_then_executes() { + let test_app = build_test_app().await; + + let dry_run_response = test_app + .app + .clone() + .oneshot(authed_request(Method::POST, "/api/dedup?ns=dup-ns", None)) + .await + .expect("dedup dry run"); + + assert_eq!(dry_run_response.status(), StatusCode::OK); + let dry_run_json: Value = response_json(dry_run_response).await; + assert!(dry_run_json["dry_run"].as_bool().unwrap()); + assert_eq!( + dry_run_json["result"]["group_by"], "source-hash-layer", + "post-v4 default must surface back to the operator on the wire" + ); + assert_eq!(dry_run_json["result"]["duplicate_groups"], 1); + assert_eq!( + dry_run_json["result"]["docs_without_hash"], 1, + "pre-v4 row with empty source_hash must be visible, not silently grouped" + ); + + let group = &dry_run_json["result"]["groups"][0]; + let expected_key = format!("dup-source|layer{}", SliceLayer::Outer.as_u8()); + assert_eq!(group["group_key"], expected_key); + assert_eq!( + group["content_hash"], expected_key, + "legacy `content_hash` field must mirror `group_key` for wire-compat" + ); + assert_eq!(group["kept_id"], "dup-keep"); + assert_eq!(group["kept_namespace"], "dup-ns"); + assert_eq!(group["removed"][0]["id"], "dup-remove"); + + let execute_response = test_app + .app + .clone() + .oneshot(authed_request( + Method::POST, + "/api/dedup?ns=dup-ns&execute=true", + Some(json!({ "confirm": true })), + )) + .await + .expect("dedup execute"); + + assert_eq!(execute_response.status(), StatusCode::OK); + let execute_json: Value = response_json(execute_response).await; + assert!(execute_json["execute"].as_bool().unwrap()); + assert_eq!(execute_json["result"]["group_by"], "source-hash-layer"); + assert_eq!(execute_json["result"]["duplicates_removed"], 1); + // 4 seeded - 1 removed = 3 (dup-keep + dup-unique + dup-pre-v4). + assert_eq!( + test_app + .storage + .count_namespace("dup-ns") + .await + .expect("post dedup count"), + 3 + ); +} + +/// Spec P4 escape hatch: operators can opt into the legacy `content-hash` +/// grouping for edge cases (e.g. backfilled corpora where source_hash was +/// never populated). Locks the wire shape: `?group-by=content-hash` flips +/// the default, `result.group_by` echoes the choice back, and a pre-v4 row +/// that shares no `content_hash` with anyone else is reported as a unique +/// doc rather than as `docs_without_hash`. +#[tokio::test] +async fn dedup_endpoint_supports_legacy_content_hash_grouping() { + let test_app = build_test_app().await; + + let dry_run_response = test_app + .app + .clone() + .oneshot(authed_request( + Method::POST, + "/api/dedup?ns=dup-ns&group-by=content-hash", + None, + )) + .await + .expect("dedup dry run"); + + assert_eq!(dry_run_response.status(), StatusCode::OK); + let dry_run_json: Value = response_json(dry_run_response).await; + assert!(dry_run_json["dry_run"].as_bool().unwrap()); + assert_eq!( + dry_run_json["result"]["group_by"], "content-hash", + "legacy opt-in must echo back so operators can audit which mode ran" + ); + assert_eq!( + dry_run_json["result"]["docs_without_hash"], 0, + "legacy mode treats every row with a content_hash as eligible" + ); + assert_eq!(dry_run_json["result"]["duplicate_groups"], 1); + let group = &dry_run_json["result"]["groups"][0]; + assert_eq!(group["group_key"], "dup-hash"); + assert_eq!( + group["content_hash"], "dup-hash", + "legacy mode keys clusters by per-chunk content_hash directly" + ); + assert_eq!(group["kept_id"], "dup-keep"); + assert_eq!(group["removed"][0]["id"], "dup-remove"); +} diff --git a/crates/rust-memex/tests/http_lifecycle_endpoints.rs b/crates/rust-memex/tests/http_lifecycle_endpoints.rs new file mode 100644 index 0000000..922dc78 --- /dev/null +++ b/crates/rust-memex/tests/http_lifecycle_endpoints.rs @@ -0,0 +1,519 @@ +use axum::{ + Json, Router, + body::{Body, to_bytes}, + extract::State, + http::{Method, Request, StatusCode, header}, + routing::post, +}; +use rust_memex::{ + AuthManager, ChromaDocument, EmbeddingClient, EmbeddingConfig, McpCore, ProviderConfig, + RAGPipeline, StorageManager, + contracts::progress::{ReindexProgress, ReprocessProgress}, + http::{HttpServerConfig, HttpState, create_router}, +}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::{net::TcpListener, sync::Mutex, task::JoinHandle}; +use tower::util::ServiceExt; + +const AUTH_TOKEN: &str = "secret-token"; +const TEST_DIMENSION: usize = 8; + +#[derive(Clone)] +struct MockEmbeddingState { + dimension: usize, +} + +#[derive(Debug, Deserialize)] +struct MockEmbeddingRequest { + input: Vec, +} + +#[derive(Debug, serde::Serialize)] +struct MockEmbeddingResponse { + data: Vec, +} + +#[derive(Debug, serde::Serialize)] +struct MockEmbeddingData { + embedding: Vec, +} + +struct MockEmbeddingServer { + base_url: String, + handle: JoinHandle<()>, +} + +impl Drop for MockEmbeddingServer { + fn drop(&mut self) { + self.handle.abort(); + } +} + +struct TestApp { + app: Router, + storage: Arc, + _tmp: TempDir, + _mock_server: MockEmbeddingServer, +} + +async fn mock_embeddings( + State(state): State, + Json(request): Json, +) -> Json { + let data = request + .input + .into_iter() + .enumerate() + .map(|(index, _)| MockEmbeddingData { + embedding: vec![index as f32 + 0.25; state.dimension], + }) + .collect(); + Json(MockEmbeddingResponse { data }) +} + +async fn start_mock_embedding_server() -> MockEmbeddingServer { + let app = Router::new() + .route("/v1/embeddings", post(mock_embeddings)) + .with_state(MockEmbeddingState { + dimension: TEST_DIMENSION, + }); + + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind mock"); + let address = listener.local_addr().expect("mock address"); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.expect("mock server"); + }); + + MockEmbeddingServer { + base_url: format!("http://{}", address), + handle, + } +} + +fn test_embedding_config(base_url: &str) -> EmbeddingConfig { + EmbeddingConfig { + required_dimension: TEST_DIMENSION, + max_batch_chars: 16_000, + max_batch_items: 8, + providers: vec![ProviderConfig { + name: "mock".to_string(), + base_url: base_url.to_string(), + model: "mock-embed".to_string(), + priority: 1, + endpoint: "/v1/embeddings".to_string(), + }], + reranker: Default::default(), + } +} + +async fn build_test_app() -> TestApp { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("lancedb"); + let mock_server = start_mock_embedding_server().await; + let embedding_config = test_embedding_config(&mock_server.base_url); + let embedding_client = Arc::new(Mutex::new( + EmbeddingClient::new(&embedding_config) + .await + .expect("embedding client"), + )); + let storage = Arc::new( + StorageManager::new(db_path.to_str().unwrap()) + .await + .expect("storage"), + ); + let rag = Arc::new( + RAGPipeline::new(embedding_client.clone(), storage.clone()) + .await + .expect("rag"), + ); + + let tokens_path = tmp.path().join("tokens.json"); + let auth_manager = Arc::new(AuthManager::new( + tokens_path.to_string_lossy().to_string(), + None, + )); + let mcp_core = Arc::new(McpCore::new( + rag.clone(), + None, + embedding_client, + 1024 * 1024, + vec![], + auth_manager, + )); + let state = HttpState::new(rag, mcp_core); + let config = HttpServerConfig { + auth_token: Some(AUTH_TOKEN.to_string()), + ..Default::default() + }; + let app = create_router(state, &config); + + TestApp { + app, + storage, + _tmp: tmp, + _mock_server: mock_server, + } +} + +fn authed_json_request(method: Method, uri: &str, body: Value) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .header(header::AUTHORIZATION, format!("Bearer {AUTH_TOKEN}")) + .body(Body::from(body.to_string())) + .expect("request") +} + +fn authed_multipart_request(uri: &str, boundary: &str, body: String) -> Request { + Request::builder() + .method(Method::POST) + .uri(uri) + .header( + header::CONTENT_TYPE, + format!("multipart/form-data; boundary={boundary}"), + ) + .header(header::AUTHORIZATION, format!("Bearer {AUTH_TOKEN}")) + .body(Body::from(body)) + .expect("multipart request") +} + +fn parse_sse_events(body: &str) -> Vec<(String, Value)> { + body.split("\n\n") + .filter_map(|chunk| { + let mut event = None; + let mut data = None; + for line in chunk.lines() { + if let Some(value) = line.strip_prefix("event:") { + event = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("data:") { + data = + Some(serde_json::from_str::(value.trim()).expect("valid sse json")); + } + } + + match (event, data) { + (Some(event), Some(data)) => Some((event, data)), + _ => None, + } + }) + .collect() +} + +fn multipart_body( + namespace: &str, + skip_existing: bool, + file_contents: &str, + boundary: &str, +) -> String { + format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"namespace\"\r\n\r\n{namespace}\r\n\ +--{boundary}\r\nContent-Disposition: form-data; name=\"skip_existing\"\r\n\r\n{skip_existing}\r\n\ +--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"export.jsonl\"\r\nContent-Type: application/x-ndjson\r\n\r\n{file_contents}\r\n\ +--{boundary}--\r\n" + ) +} + +#[tokio::test] +async fn reprocess_endpoint_streams_progress_and_writes_target_namespace() { + let test_app = build_test_app().await; + let input_path = test_app._tmp.path().join("reprocess.jsonl"); + let payload = [ + json!({ + "id": "doc-1:outer", + "text": "Short summary", + "metadata": {"original_id": "doc-1", "layer": "outer"}, + "content_hash": "outer-hash" + }), + json!({ + "id": "doc-1:core", + "text": "Longer full document content that should survive collapse.", + "metadata": {"original_id": "doc-1", "layer": "core"}, + "content_hash": "core-hash" + }), + ] + .into_iter() + .map(|row| row.to_string()) + .collect::>() + .join("\n"); + tokio::fs::write(&input_path, payload) + .await + .expect("write jsonl"); + + let response = test_app + .app + .clone() + .oneshot(authed_json_request( + Method::POST, + "/sse/reprocess", + json!({ + "input_path": input_path, + "target_namespace": "reprocessed-ns", + "slice_mode": "flat", + "preprocess": false, + "skip_existing": false + }), + )) + .await + .expect("sse response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("sse body"); + let text = String::from_utf8(body.to_vec()).expect("utf8"); + let events = parse_sse_events(&text); + assert!(events.iter().any(|(event, _)| event == "start")); + assert!(events.iter().any(|(event, _)| event == "progress")); + let result = events + .iter() + .find(|(event, _)| event == "result") + .map(|(_, data)| data.clone()) + .expect("result event"); + let progress = events + .iter() + .find(|(event, _)| event == "progress") + .map(|(_, data)| { + serde_json::from_value::(data.clone()).expect("progress") + }) + .expect("typed progress"); + + assert_eq!(progress.source_label, input_path.display().to_string()); + assert_eq!(result["indexed_documents"], 1); + assert_eq!( + test_app + .storage + .count_namespace("reprocessed-ns") + .await + .expect("count"), + 1 + ); +} + +#[tokio::test] +async fn reindex_endpoint_defaults_target_namespace() { + let test_app = build_test_app().await; + test_app + .storage + .add_to_store(vec![ChromaDocument::new_flat_with_hash( + "src-doc".to_string(), + "source-ns".to_string(), + vec![0.4; TEST_DIMENSION], + json!({"doc_id": "src-doc"}), + "Reindex me".to_string(), + "hash-src-doc".to_string(), + )]) + .await + .expect("seed source"); + + let response = test_app + .app + .clone() + .oneshot(authed_json_request( + Method::POST, + "/sse/reindex", + json!({ + "source_namespace": "source-ns", + "slice_mode": "flat", + "preprocess": false, + "skip_existing": false + }), + )) + .await + .expect("reindex response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("sse body"); + let text = String::from_utf8(body.to_vec()).expect("utf8"); + let events = parse_sse_events(&text); + let progress = events + .iter() + .find(|(event, _)| event == "progress") + .map(|(_, data)| serde_json::from_value::(data.clone()).expect("progress")) + .expect("typed progress"); + let result = events + .iter() + .find(|(event, _)| event == "result") + .map(|(_, data)| data.clone()) + .expect("result event"); + + assert_eq!(progress.namespace, "source-ns-reindexed"); + assert_eq!(result["target_namespace"], "source-ns-reindexed"); + assert_eq!( + test_app + .storage + .count_namespace("source-ns-reindexed") + .await + .expect("count"), + 1 + ); +} + +#[tokio::test] +async fn export_import_round_trip_and_migrate_namespace_work() { + let test_app = build_test_app().await; + test_app + .storage + .add_to_store(vec![ + ChromaDocument::new_flat_with_hash( + "doc-a".to_string(), + "export-src".to_string(), + vec![0.1; TEST_DIMENSION], + json!({"kind": "note"}), + "Alpha body".to_string(), + "hash-alpha".to_string(), + ), + ChromaDocument::new_flat_with_hash( + "doc-b".to_string(), + "export-src".to_string(), + vec![0.2; TEST_DIMENSION], + json!({"kind": "note"}), + "Beta body".to_string(), + "hash-beta".to_string(), + ), + ]) + .await + .expect("seed export source"); + + let export_response = test_app + .app + .clone() + .oneshot(authed_json_request( + Method::POST, + "/api/export", + json!({ + "namespace": "export-src", + "include_embeddings": true + }), + )) + .await + .expect("export response"); + + assert_eq!(export_response.status(), StatusCode::OK); + assert_eq!( + export_response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/x-ndjson" + ); + let exported_bytes = to_bytes(export_response.into_body(), 1024 * 1024) + .await + .expect("export body"); + let exported_jsonl = String::from_utf8(exported_bytes.to_vec()).expect("utf8"); + let exported_rows = exported_jsonl + .lines() + .map(|line| serde_json::from_str::(line).expect("json line")) + .collect::>(); + assert_eq!(exported_rows.len(), 2); + let exported_hashes = exported_rows + .iter() + .map(|row| { + row["content_hash"] + .as_str() + .expect("exported content hash") + .to_string() + }) + .collect::>(); + assert_eq!(exported_hashes, vec!["hash-alpha", "hash-beta"]); + + let boundary = "memex-boundary"; + let import_body = multipart_body("import-copy", false, &exported_jsonl, boundary); + let import_response = test_app + .app + .clone() + .oneshot(authed_multipart_request( + "/api/import", + boundary, + import_body, + )) + .await + .expect("import response"); + + assert_eq!(import_response.status(), StatusCode::OK); + let import_json: Value = serde_json::from_slice( + &to_bytes(import_response.into_body(), 64 * 1024) + .await + .expect("import body"), + ) + .expect("import json"); + assert_eq!(import_json["imported_count"], 2); + assert_eq!( + import_json["imported_count"] + .as_u64() + .expect("imported count") as usize, + exported_rows.len() + ); + assert_eq!( + test_app + .storage + .count_namespace("import-copy") + .await + .expect("import count"), + 2 + ); + + test_app + .storage + .add_to_store(vec![ + ChromaDocument::new_flat_with_hash( + "move-a".to_string(), + "rename-me".to_string(), + vec![0.3; TEST_DIMENSION], + json!({"kind": "rename"}), + "Move alpha".to_string(), + "hash-move-a".to_string(), + ), + ChromaDocument::new_flat_with_hash( + "move-b".to_string(), + "rename-me".to_string(), + vec![0.4; TEST_DIMENSION], + json!({"kind": "rename"}), + "Move beta".to_string(), + "hash-move-b".to_string(), + ), + ]) + .await + .expect("seed migrate"); + + let migrate_response = test_app + .app + .clone() + .oneshot(authed_json_request( + Method::POST, + "/api/migrate-namespace", + json!({ + "from": "rename-me", + "to": "renamed-ns" + }), + )) + .await + .expect("migrate response"); + + assert_eq!(migrate_response.status(), StatusCode::OK); + let migrate_json: Value = serde_json::from_slice( + &to_bytes(migrate_response.into_body(), 64 * 1024) + .await + .expect("migrate body"), + ) + .expect("migrate json"); + assert_eq!(migrate_json["migrated_chunks"], 2); + assert_eq!( + test_app + .storage + .count_namespace("rename-me") + .await + .expect("old count"), + 0 + ); + assert_eq!( + test_app + .storage + .count_namespace("renamed-ns") + .await + .expect("new count"), + 2 + ); +} diff --git a/crates/rust-memex/tests/http_recovery_endpoints.rs b/crates/rust-memex/tests/http_recovery_endpoints.rs new file mode 100644 index 0000000..f9378aa --- /dev/null +++ b/crates/rust-memex/tests/http_recovery_endpoints.rs @@ -0,0 +1,564 @@ +use axum::{ + Json, Router, + body::{Body, to_bytes}, + extract::State, + http::{Method, Request, StatusCode, header}, + routing::post, +}; +use rust_memex::{ + AuthManager, BM25Config, BM25Index, ChromaDocument, CrossStoreRecoveryBatch, EmbeddingClient, + EmbeddingConfig, McpCore, ProviderConfig, RAGPipeline, StorageManager, + http::{HttpServerConfig, HttpState, create_router}, +}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::{net::TcpListener, sync::Mutex, task::JoinHandle}; +use tower::util::ServiceExt; + +const AUTH_TOKEN: &str = "secret-token"; +const TEST_DIMENSION: usize = 8; + +#[derive(Clone)] +struct MockEmbeddingState { + dimension: usize, +} + +#[derive(Debug, Deserialize)] +struct MockEmbeddingRequest { + input: Vec, +} + +#[derive(Debug, serde::Serialize)] +struct MockEmbeddingResponse { + data: Vec, +} + +#[derive(Debug, serde::Serialize)] +struct MockEmbeddingData { + embedding: Vec, +} + +struct MockEmbeddingServer { + base_url: String, + handle: JoinHandle<()>, +} + +impl Drop for MockEmbeddingServer { + fn drop(&mut self) { + self.handle.abort(); + } +} + +struct TestApp { + app: Router, + storage: Arc, + tmp: TempDir, + _mock_server: MockEmbeddingServer, +} + +async fn mock_embeddings( + State(state): State, + Json(request): Json, +) -> Json { + let data = request + .input + .into_iter() + .enumerate() + .map(|(index, _)| MockEmbeddingData { + embedding: vec![index as f32 + 0.25; state.dimension], + }) + .collect(); + Json(MockEmbeddingResponse { data }) +} + +async fn start_mock_embedding_server() -> MockEmbeddingServer { + let app = Router::new() + .route("/v1/embeddings", post(mock_embeddings)) + .with_state(MockEmbeddingState { + dimension: TEST_DIMENSION, + }); + + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind mock"); + let address = listener.local_addr().expect("mock address"); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.expect("mock server"); + }); + + MockEmbeddingServer { + base_url: format!("http://{}", address), + handle, + } +} + +fn test_embedding_config(base_url: &str) -> EmbeddingConfig { + EmbeddingConfig { + required_dimension: TEST_DIMENSION, + max_batch_chars: 16_000, + max_batch_items: 8, + providers: vec![ProviderConfig { + name: "mock".to_string(), + base_url: base_url.to_string(), + model: "mock-embed".to_string(), + priority: 1, + endpoint: "/v1/embeddings".to_string(), + }], + reranker: Default::default(), + } +} + +async fn build_test_app() -> TestApp { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("lancedb"); + let mock_server = start_mock_embedding_server().await; + let embedding_config = test_embedding_config(&mock_server.base_url); + let embedding_client = Arc::new(Mutex::new( + EmbeddingClient::new(&embedding_config) + .await + .expect("embedding client"), + )); + let storage = Arc::new( + StorageManager::new(db_path.to_str().unwrap()) + .await + .expect("storage"), + ); + let rag = Arc::new( + RAGPipeline::new(embedding_client.clone(), storage.clone()) + .await + .expect("rag"), + ); + + let tokens_path = tmp.path().join("tokens.json"); + let auth_manager = Arc::new(AuthManager::new( + tokens_path.to_string_lossy().to_string(), + None, + )); + let mcp_core = Arc::new(McpCore::new( + rag.clone(), + None, + embedding_client, + 1024 * 1024, + vec![], + auth_manager, + )); + let state = HttpState::new(rag, mcp_core); + let config = HttpServerConfig { + auth_token: Some(AUTH_TOKEN.to_string()), + ..Default::default() + }; + let app = create_router(state, &config); + + TestApp { + app, + storage, + tmp, + _mock_server: mock_server, + } +} + +fn authed_json_request(method: Method, uri: &str, body: Value) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .header(header::AUTHORIZATION, format!("Bearer {AUTH_TOKEN}")) + .body(Body::from(body.to_string())) + .expect("request") +} + +fn parse_sse_events(body: &str) -> Vec<(String, Value)> { + body.split("\n\n") + .filter_map(|chunk| { + let mut event = None; + let mut data = None; + for line in chunk.lines() { + if let Some(value) = line.strip_prefix("event:") { + event = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("data:") { + data = + Some(serde_json::from_str::(value.trim()).expect("valid sse json")); + } + } + + match (event, data) { + (Some(event), Some(data)) => Some((event, data)), + _ => None, + } + }) + .collect() +} + +async fn seed_doc(storage: &StorageManager, id: &str, namespace: &str, text: &str, hash: &str) { + storage + .add_to_store(vec![ChromaDocument::new_flat_with_hash( + id.to_string(), + namespace.to_string(), + vec![0.1; TEST_DIMENSION], + json!({"kind": "test"}), + text.to_string(), + hash.to_string(), + )]) + .await + .expect("seed doc"); +} + +#[tokio::test] +async fn merge_endpoint_dry_run_and_execute_work() { + let test_app = build_test_app().await; + let source_one_path = test_app.tmp.path().join("merge-src-1"); + let source_two_path = test_app.tmp.path().join("merge-src-2"); + let target_path = test_app.tmp.path().join("merge-target"); + + let source_one = StorageManager::new_lance_only(source_one_path.to_str().unwrap()) + .await + .expect("source one"); + let source_two = StorageManager::new_lance_only(source_two_path.to_str().unwrap()) + .await + .expect("source two"); + + seed_doc( + &source_one, + "doc-a", + "alpha", + "Alpha merge payload", + "merge-hash-a", + ) + .await; + seed_doc( + &source_two, + "doc-b", + "beta", + "Beta merge payload", + "merge-hash-b", + ) + .await; + + let dry_run_response = test_app + .app + .clone() + .oneshot(authed_json_request( + Method::POST, + "/api/merge", + json!({ + "sources": [ + source_one_path.display().to_string(), + source_two_path.display().to_string() + ], + "target": target_path.display().to_string(), + "namespace_prefix": "merged:", + "dedup": false, + "dry_run": true + }), + )) + .await + .expect("dry-run merge response"); + + assert_eq!(dry_run_response.status(), StatusCode::OK); + let dry_run_json: Value = serde_json::from_slice( + &to_bytes(dry_run_response.into_body(), 128 * 1024) + .await + .expect("dry-run merge body"), + ) + .expect("dry-run merge json"); + assert!(dry_run_json["dry_run"].as_bool().unwrap()); + assert_eq!(dry_run_json["progress"]["total_docs"], 2); + assert_eq!(dry_run_json["progress"]["docs_copied"], 2); + assert_eq!( + dry_run_json["progress"]["namespaces"], + json!(["merged:alpha", "merged:beta"]) + ); + + let target_storage = StorageManager::new_lance_only(target_path.to_str().unwrap()) + .await + .expect("target storage"); + assert_eq!( + target_storage + .count_namespace("merged:alpha") + .await + .expect("count"), + 0 + ); + + let execute_response = test_app + .app + .clone() + .oneshot(authed_json_request( + Method::POST, + "/api/merge", + json!({ + "sources": [ + source_one_path.display().to_string(), + source_two_path.display().to_string() + ], + "target": target_path.display().to_string(), + "namespace_prefix": "merged:", + "dedup": false, + "dry_run": false + }), + )) + .await + .expect("execute merge response"); + + assert_eq!(execute_response.status(), StatusCode::OK); + let execute_json: Value = serde_json::from_slice( + &to_bytes(execute_response.into_body(), 128 * 1024) + .await + .expect("execute merge body"), + ) + .expect("execute merge json"); + assert_eq!(execute_json["progress"]["sources_processed"], 2); + assert_eq!( + target_storage + .count_namespace("merged:alpha") + .await + .expect("count"), + 1 + ); + assert_eq!( + target_storage + .count_namespace("merged:beta") + .await + .expect("count"), + 1 + ); +} + +#[tokio::test] +async fn repair_writes_endpoint_inspects_and_executes_recovery() { + let test_app = build_test_app().await; + let repair_doc = ChromaDocument::new_flat_with_hash( + "repair-doc".to_string(), + "repair-http".to_string(), + vec![0.2; TEST_DIMENSION], + json!({"kind": "repair"}), + "Recoverable repair payload".to_string(), + "repair-hash".to_string(), + ); + let recovery_batch = CrossStoreRecoveryBatch::from_documents(std::slice::from_ref(&repair_doc)); + + test_app + .storage + .persist_cross_store_recovery_batch(&recovery_batch) + .expect("persist recovery batch"); + test_app + .storage + .add_to_store(vec![repair_doc]) + .await + .expect("seed repair doc"); + + let inspect_response = test_app + .app + .clone() + .oneshot(authed_json_request( + Method::POST, + "/api/repair-writes", + json!({ + "namespace": "repair-http", + "execute": false, + "json_output": true + }), + )) + .await + .expect("inspect response"); + + assert_eq!(inspect_response.status(), StatusCode::OK); + let inspect_json: Value = serde_json::from_slice( + &to_bytes(inspect_response.into_body(), 128 * 1024) + .await + .expect("inspect body"), + ) + .expect("inspect json"); + assert_eq!(inspect_json["pending_batches"], 1); + assert_eq!(inspect_json["divergent_batches"], 1); + assert_eq!(inspect_json["documents_missing_bm25"], 1); + + let execute_response = test_app + .app + .clone() + .oneshot(authed_json_request( + Method::POST, + "/api/repair-writes", + json!({ + "namespace": "repair-http", + "execute": true, + "json_output": false + }), + )) + .await + .expect("execute response"); + + assert_eq!(execute_response.status(), StatusCode::OK); + let execute_json: Value = serde_json::from_slice( + &to_bytes(execute_response.into_body(), 64 * 1024) + .await + .expect("execute body"), + ) + .expect("execute json"); + assert_eq!(execute_json["pending_batches"], 1); + assert_eq!(execute_json["repaired_documents"], 1); + assert_eq!(execute_json["batches_repaired"], 1); + assert!( + test_app + .storage + .list_cross_store_recovery_batches() + .expect("recovery batches") + .is_empty() + ); + + let bm25_path = test_app.tmp.path().join(".bm25"); + let bm25 = + BM25Index::new(&BM25Config::default().with_path(bm25_path.to_string_lossy().into_owned())) + .expect("bm25 index"); + let bm25_results = bm25 + .search("recoverable", Some("repair-http"), 10) + .expect("bm25 search"); + assert_eq!(bm25_results.len(), 1); + assert_eq!(bm25_results[0].0, "repair-doc"); +} + +#[tokio::test] +async fn recovery_sse_endpoints_stream_independent_operations_and_alias() { + let test_app = build_test_app().await; + seed_doc( + test_app.storage.as_ref(), + "compact-doc", + "compact-http", + "Compaction target document", + "compact-hash", + ) + .await; + + let compact_response = test_app + .app + .clone() + .oneshot(authed_json_request(Method::POST, "/sse/compact", json!({}))) + .await + .expect("compact response"); + assert_eq!(compact_response.status(), StatusCode::OK); + let compact_text = String::from_utf8( + to_bytes(compact_response.into_body(), 256 * 1024) + .await + .expect("compact body") + .to_vec(), + ) + .expect("compact utf8"); + let compact_events = parse_sse_events(&compact_text); + assert!(compact_events.iter().any(|(event, _)| event == "start")); + assert!( + compact_events + .iter() + .any(|(event, _)| event == "compact_done") + ); + assert!( + compact_events + .iter() + .find(|(event, _)| event == "done") + .map(|(_, data)| data["ok"].as_bool().unwrap_or(false)) + .unwrap_or(false) + ); + + let cleanup_response = test_app + .app + .clone() + .oneshot(authed_json_request(Method::POST, "/sse/cleanup", json!({}))) + .await + .expect("cleanup response"); + assert_eq!(cleanup_response.status(), StatusCode::OK); + let cleanup_text = String::from_utf8( + to_bytes(cleanup_response.into_body(), 256 * 1024) + .await + .expect("cleanup body") + .to_vec(), + ) + .expect("cleanup utf8"); + let cleanup_events = parse_sse_events(&cleanup_text); + assert!( + cleanup_events + .iter() + .any(|(event, _)| event == "cleanup_done") + ); + + let mut orphan_doc = ChromaDocument::new_flat_with_hash( + "orphan-doc".to_string(), + "gc-http".to_string(), + vec![0.3; TEST_DIMENSION], + json!({"kind": "orphan"}), + "Orphan document".to_string(), + "gc-hash".to_string(), + ); + orphan_doc.parent_id = Some("missing-parent".to_string()); + test_app + .storage + .add_to_store(vec![orphan_doc]) + .await + .expect("seed orphan"); + + let gc_response = test_app + .app + .clone() + .oneshot(authed_json_request(Method::POST, "/sse/gc", json!({}))) + .await + .expect("gc response"); + assert_eq!(gc_response.status(), StatusCode::OK); + let gc_text = String::from_utf8( + to_bytes(gc_response.into_body(), 256 * 1024) + .await + .expect("gc body") + .to_vec(), + ) + .expect("gc utf8"); + let gc_events = parse_sse_events(&gc_text); + let gc_done = gc_events + .iter() + .find(|(event, _)| event == "gc_done") + .map(|(_, data)| data.clone()) + .expect("gc done event"); + assert_eq!(gc_done["orphans_found"], 1); + assert_eq!(gc_done["orphans_removed"], 1); + assert_eq!( + test_app + .storage + .count_namespace("gc-http") + .await + .expect("gc namespace count"), + 0 + ); + + let optimize_response = test_app + .app + .clone() + .oneshot(authed_json_request( + Method::POST, + "/sse/optimize", + json!({}), + )) + .await + .expect("optimize response"); + assert_eq!(optimize_response.status(), StatusCode::OK); + let optimize_text = String::from_utf8( + to_bytes(optimize_response.into_body(), 256 * 1024) + .await + .expect("optimize body") + .to_vec(), + ) + .expect("optimize utf8"); + let optimize_events = parse_sse_events(&optimize_text); + assert!( + optimize_events + .iter() + .any(|(event, _)| event == "compact_done") + ); + assert!( + optimize_events + .iter() + .any(|(event, _)| event == "prune_done") + ); + let optimize_done = optimize_events + .iter() + .find(|(event, _)| event == "done") + .map(|(_, data)| data.clone()) + .expect("optimize done"); + assert!(optimize_done["compact_ok"].as_bool().unwrap()); + assert!(optimize_done["prune_ok"].as_bool().unwrap()); +} diff --git a/crates/rust-memex/tests/http_schema_mismatch.rs b/crates/rust-memex/tests/http_schema_mismatch.rs new file mode 100644 index 0000000..beb2337 --- /dev/null +++ b/crates/rust-memex/tests/http_schema_mismatch.rs @@ -0,0 +1,191 @@ +use std::{ + io::{self, Write}, + sync::{Arc, Mutex as StdMutex}, +}; + +use arrow_schema::{DataType, Field, Schema}; +use axum::{ + Router, + body::{Body, to_bytes}, + http::{Method, Request, StatusCode, header}, +}; +use lancedb::connect; +use rust_memex::{ + AuthManager, DEFAULT_TABLE_NAME, EmbeddingClient, McpCore, RAGPipeline, StorageManager, + http::{HttpServerConfig, HttpState, create_router}, +}; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::sync::Mutex; +use tower::util::ServiceExt; +use tracing_subscriber::fmt::MakeWriter; + +const AUTH_TOKEN: &str = "secret-token"; +const TEST_DIMENSION: usize = 8; + +struct TestApp { + app: Router, + db_path: String, + _tmp: TempDir, +} + +#[derive(Clone)] +struct CapturedLogs(Arc>>); + +struct CapturedLogWriter(Arc>>); + +impl Write for CapturedLogWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0 + .lock() + .expect("log buffer poisoned") + .extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl<'a> MakeWriter<'a> for CapturedLogs { + type Writer = CapturedLogWriter; + + fn make_writer(&'a self) -> Self::Writer { + CapturedLogWriter(self.0.clone()) + } +} + +async fn build_test_app() -> TestApp { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("lancedb"); + let embedding_client = Arc::new(Mutex::new(EmbeddingClient::stub_for_tests())); + let storage = Arc::new( + StorageManager::new(db_path.to_str().unwrap()) + .await + .expect("storage"), + ); + let rag = Arc::new( + RAGPipeline::new(embedding_client.clone(), storage) + .await + .expect("rag"), + ); + + let tokens_path = tmp.path().join("tokens.json"); + let auth_manager = Arc::new(AuthManager::new( + tokens_path.to_string_lossy().to_string(), + None, + )); + let mcp_core = Arc::new(McpCore::new( + rag.clone(), + None, + embedding_client, + 1024 * 1024, + vec![], + auth_manager, + )); + let state = HttpState::new(rag, mcp_core); + let config = HttpServerConfig { + auth_token: Some(AUTH_TOKEN.to_string()), + ..Default::default() + }; + let app = create_router(state, &config); + + TestApp { + app, + db_path: db_path.to_string_lossy().to_string(), + _tmp: tmp, + } +} + +async fn create_pre_v4_table(db_path: &str) { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Utf8, false), + Field::new("namespace", DataType::Utf8, false), + Field::new( + "vector", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Float32, true)), + TEST_DIMENSION as i32, + ), + false, + ), + Field::new("text", DataType::Utf8, true), + Field::new("metadata", DataType::Utf8, true), + Field::new("layer", DataType::UInt8, true), + Field::new("parent_id", DataType::Utf8, true), + Field::new("children_ids", DataType::Utf8, true), + Field::new("keywords", DataType::Utf8, true), + Field::new("content_hash", DataType::Utf8, true), + ])); + connect(db_path) + .execute() + .await + .expect("connect lancedb") + .create_empty_table(DEFAULT_TABLE_NAME, schema) + .execute() + .await + .expect("create pre-v4 table"); +} + +fn authed_json_request(method: Method, uri: &str, body: Value) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .header(header::AUTHORIZATION, format!("Bearer {AUTH_TOKEN}")) + .body(Body::from(body.to_string())) + .expect("request") +} + +#[tokio::test] +async fn upsert_schema_mismatch_returns_412_with_structured_body_and_error_log() { + let test_app = build_test_app().await; + create_pre_v4_table(&test_app.db_path).await; + + let log_buffer = Arc::new(StdMutex::new(Vec::new())); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::ERROR) + .with_writer(CapturedLogs(log_buffer.clone())) + .with_ansi(false) + .finish(); + let _guard = tracing::subscriber::set_default(subscriber); + + let response = test_app + .app + .clone() + .oneshot(authed_json_request( + Method::POST, + "/upsert", + json!({ + "namespace": "legacy-ns", + "id": "doc-1", + "content": "schema mismatch should fail loudly", + "metadata": {"slice_mode": "flat"} + }), + )) + .await + .expect("upsert response"); + + assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED); + let body = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("body bytes"); + let json: Value = serde_json::from_slice(&body).expect("json body"); + assert_eq!(json["error"], "schema_mismatch"); + assert_eq!(json["error_kind"], "schema_mismatch"); + assert_eq!(json["missing_columns"], json!(["source_hash"])); + assert!( + json["remediation"] + .as_str() + .expect("remediation") + .contains("rust-memex migrate-schema --db-path") + ); + + let logs = + String::from_utf8(log_buffer.lock().expect("log buffer poisoned").clone()).expect("utf8"); + assert!(logs.contains("ERROR"), "{logs}"); + assert!(logs.contains("schema_mismatch"), "{logs}"); + assert!(logs.contains("source_hash"), "{logs}"); + assert!(logs.contains("rust-memex migrate-schema"), "{logs}"); +} diff --git a/crates/rust-memex/tests/migrate_schema.rs b/crates/rust-memex/tests/migrate_schema.rs new file mode 100644 index 0000000..77b4d13 --- /dev/null +++ b/crates/rust-memex/tests/migrate_schema.rs @@ -0,0 +1,124 @@ +use std::process::Command; +use std::sync::Arc; + +use arrow_schema::{DataType, Field, Schema}; +use rust_memex::DEFAULT_TABLE_NAME; + +fn rust_memex_bin() -> &'static str { + env!("CARGO_BIN_EXE_rust-memex") +} + +fn pre_v4_schema() -> Schema { + Schema::new(vec![ + Field::new("id", DataType::Utf8, false), + Field::new("namespace", DataType::Utf8, false), + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 3), + false, + ), + Field::new("text", DataType::Utf8, true), + Field::new("metadata", DataType::Utf8, true), + Field::new("layer", DataType::UInt8, true), + Field::new("parent_id", DataType::Utf8, true), + Field::new("children_ids", DataType::Utf8, true), + Field::new("keywords", DataType::Utf8, true), + Field::new("content_hash", DataType::Utf8, true), + ]) +} + +#[tokio::test] +async fn migrate_schema_adds_source_hash_and_rerun_is_noop() { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("lancedb"); + let db_path_str = db_path.to_string_lossy().to_string(); + + let db = lancedb::connect(&db_path_str) + .execute() + .await + .expect("connect lancedb"); + db.create_empty_table(DEFAULT_TABLE_NAME, Arc::new(pre_v4_schema())) + .execute() + .await + .expect("create pre-v4 table"); + + let check = Command::new(rust_memex_bin()) + .args([ + "--db-path", + db_path_str.as_str(), + "migrate-schema", + "--check-only", + ]) + .output() + .expect("run check-only"); + assert!( + !check.status.success(), + "check-only should exit non-zero when migration is needed" + ); + let check_stdout = String::from_utf8_lossy(&check.stdout); + assert!( + check_stdout.contains(r#"Migration needed. Missing columns: ["source_hash"]"#), + "unexpected check-only stdout: {check_stdout}" + ); + + let migrate = Command::new(rust_memex_bin()) + .args(["--db-path", db_path_str.as_str(), "migrate-schema"]) + .output() + .expect("run migrate-schema"); + assert!( + migrate.status.success(), + "migrate-schema failed: stdout={} stderr={}", + String::from_utf8_lossy(&migrate.stdout), + String::from_utf8_lossy(&migrate.stderr) + ); + let migrate_stdout = String::from_utf8_lossy(&migrate.stdout); + assert!( + migrate_stdout.contains("Migration complete. Schema is now v4."), + "unexpected migrate stdout: {migrate_stdout}" + ); + + let backfill = Command::new(rust_memex_bin()) + .args([ + "--db-path", + db_path_str.as_str(), + "backfill-hashes", + "--dry-run", + "false", + ]) + .output() + .expect("run backfill-hashes after schema migration"); + assert!( + backfill.status.success(), + "backfill-hashes failed after migration: stdout={} stderr={}", + String::from_utf8_lossy(&backfill.stdout), + String::from_utf8_lossy(&backfill.stderr) + ); + + let table = db + .open_table(DEFAULT_TABLE_NAME) + .execute() + .await + .expect("open migrated table"); + table.checkout_latest().await.expect("checkout latest"); + let schema = table.schema().await.expect("read migrated schema"); + assert!( + schema.field_with_name("source_hash").is_ok(), + "source_hash should be present after migration" + ); + + let rerun = Command::new(rust_memex_bin()) + .args(["--db-path", db_path_str.as_str(), "migrate-schema"]) + .output() + .expect("rerun migrate-schema"); + assert!( + rerun.status.success(), + "rerun failed: stdout={} stderr={}", + String::from_utf8_lossy(&rerun.stdout), + String::from_utf8_lossy(&rerun.stderr) + ); + let rerun_stdout = String::from_utf8_lossy(&rerun.stdout); + assert!( + rerun_stdout.contains("Schema is up-to-date (target=v4). No migration needed."), + "unexpected rerun stdout: {rerun_stdout}" + ); +} diff --git a/crates/rust-memex/tests/startup_guard.rs b/crates/rust-memex/tests/startup_guard.rs new file mode 100644 index 0000000..b655a87 --- /dev/null +++ b/crates/rust-memex/tests/startup_guard.rs @@ -0,0 +1,187 @@ +use std::{net::TcpListener, sync::Arc, time::Duration}; + +use arrow_schema::{DataType, Field, Schema}; +use rust_memex::DEFAULT_TABLE_NAME; +use tokio::{ + process::Command, + time::{sleep, timeout}, +}; + +fn rust_memex_bin() -> &'static str { + env!("CARGO_BIN_EXE_rust-memex") +} + +fn free_port() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind free port"); + listener.local_addr().expect("local addr").port() +} + +fn schema(include_source_hash: bool) -> Schema { + let mut fields = vec![ + Field::new("id", DataType::Utf8, false), + Field::new("namespace", DataType::Utf8, false), + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 3), + false, + ), + Field::new("text", DataType::Utf8, true), + Field::new("metadata", DataType::Utf8, true), + Field::new("layer", DataType::UInt8, true), + Field::new("parent_id", DataType::Utf8, true), + Field::new("children_ids", DataType::Utf8, true), + Field::new("keywords", DataType::Utf8, true), + Field::new("content_hash", DataType::Utf8, true), + ]; + + if include_source_hash { + fields.push(Field::new("source_hash", DataType::Utf8, true)); + } + + Schema::new(fields) +} + +async fn create_table(db_path: &str, include_source_hash: bool) { + lancedb::connect(db_path) + .execute() + .await + .expect("connect lancedb") + .create_empty_table(DEFAULT_TABLE_NAME, Arc::new(schema(include_source_hash))) + .execute() + .await + .expect("create table"); +} + +async fn assert_check_only_sees_migration_needed(db_path: &str) { + let output = Command::new(rust_memex_bin()) + .args(["--db-path", db_path, "migrate-schema", "--check-only"]) + .output() + .await + .expect("run migrate-schema --check-only"); + assert!( + !output.status.success(), + "check-only should fail on pre-v4 schema: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +async fn stderr_from_timed_daemon(args: &[String], runtime: Duration) -> String { + let mut child = Command::new(rust_memex_bin()) + .args(args) + .stderr(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .expect("spawn daemon"); + + sleep(runtime).await; + let _ = child.kill().await; + let output = child + .wait_with_output() + .await + .expect("collect daemon output"); + String::from_utf8_lossy(&output.stderr).to_string() +} + +#[tokio::test] +async fn daemon_startup_refuses_pre_v4_schema_without_auto_migrate() { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("lancedb").to_string_lossy().to_string(); + create_table(&db_path, false).await; + assert_check_only_sees_migration_needed(&db_path).await; + + let output = timeout( + Duration::from_secs(5), + Command::new(rust_memex_bin()) + .args([ + "--db-path", + db_path.as_str(), + "sse", + "--port", + &free_port().to_string(), + ]) + .output(), + ) + .await + .expect("daemon should fail before binding") + .expect("run daemon"); + + assert!( + !output.status.success(), + "daemon should refuse pre-v4 schema without --auto-migrate" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("ERROR: binary requires schema v4, table is v3-pre."), + "unexpected stderr: {stderr}" + ); + assert!( + stderr.contains("Run 'rust-memex migrate-schema --db-path"), + "unexpected stderr: {stderr}" + ); + assert!( + stderr.contains("Or pass --auto-migrate to migrate at startup."), + "unexpected stderr: {stderr}" + ); +} + +#[tokio::test] +async fn daemon_startup_auto_migrates_pre_v4_schema() { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("lancedb").to_string_lossy().to_string(); + create_table(&db_path, false).await; + assert_check_only_sees_migration_needed(&db_path).await; + + let args = vec![ + "--db-path".to_string(), + db_path.clone(), + "--auto-migrate".to_string(), + "sse".to_string(), + "--port".to_string(), + free_port().to_string(), + ]; + let stderr = stderr_from_timed_daemon(&args, Duration::from_secs(2)).await; + assert!( + stderr.contains("migrating schema v3-pre -> v4 at startup"), + "unexpected stderr: {stderr}" + ); + + let table = lancedb::connect(&db_path) + .execute() + .await + .expect("connect") + .open_table(DEFAULT_TABLE_NAME) + .execute() + .await + .expect("open migrated table"); + table.checkout_latest().await.expect("checkout latest"); + let schema = table.schema().await.expect("schema"); + assert!( + schema.field_with_name("source_hash").is_ok(), + "source_hash should exist after --auto-migrate" + ); +} + +#[tokio::test] +async fn daemon_startup_on_v4_schema_has_no_schema_guard_noise() { + let tmp = tempfile::tempdir().expect("tempdir"); + let db_path = tmp.path().join("lancedb").to_string_lossy().to_string(); + create_table(&db_path, true).await; + + let args = vec![ + "--db-path".to_string(), + db_path, + "sse".to_string(), + "--port".to_string(), + free_port().to_string(), + ]; + let stderr = stderr_from_timed_daemon(&args, Duration::from_secs(2)).await; + assert!( + !stderr.contains("binary requires schema"), + "unexpected stderr: {stderr}" + ); + assert!( + !stderr.contains("migrating schema"), + "unexpected stderr: {stderr}" + ); +} diff --git a/tests/transport_parity.rs b/crates/rust-memex/tests/transport_parity.rs similarity index 68% rename from tests/transport_parity.rs rename to crates/rust-memex/tests/transport_parity.rs index 1a74560..3972186 100644 --- a/tests/transport_parity.rs +++ b/crates/rust-memex/tests/transport_parity.rs @@ -5,48 +5,33 @@ //! through both `McpTransport::Stdio` and `McpTransport::HttpSse` and //! asserts structurally equivalent responses. //! -//! These tests require a running embedding server (Ollama with qwen3-embedding:8b, 4096 dims). -//! Run with: cargo test --test transport_parity -- --ignored +//! Gated by `e2e-ollama` feature: the full file is only compiled when +//! invoked via `make test-e2e` (or `cargo test --features e2e-ollama +//! --test transport_parity`). `build_mcp_core` requires a reachable +//! embedding provider; tests load the operator's canonical config.toml +//! and rely on its real provider cascade — no hardcoded localhost URLs. //! -//! Vibecrafted with AI Agents (c)2026 Loctree +//! Vibecrafted with AI Agents by VetCoders (c)2024-2026 LibraxisAI + +#![cfg(feature = "e2e-ollama")] + +mod common; use rust_memex::{ - EmbeddingConfig, HybridConfig, McpTransport, NamespaceSecurityConfig, ProviderConfig, - ServerConfig, build_mcp_core, dispatch_mcp_payload, + HybridConfig, McpTransport, NamespaceSecurityConfig, ServerConfig, build_mcp_core, + dispatch_mcp_payload, }; use serde_json::{Value, json}; use tempfile::TempDir; -const LOCAL_OLLAMA_MODEL: &str = "qwen3-embedding:8b"; -const LOCAL_OLLAMA_DIMENSION: usize = 4096; - // ============================================================================= // Helpers // ============================================================================= -async fn ollama_available() -> bool { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build() - .unwrap(); - - let Ok(resp) = client.get("http://localhost:11434/api/tags").send().await else { - return false; - }; - let Ok(body) = resp.json::().await else { - return false; - }; - // Check the specific model is pulled, not just that Ollama is running - body["models"].as_array().is_some_and(|models| { - models.iter().any(|m| { - m["name"] - .as_str() - .is_some_and(|n| n.starts_with(LOCAL_OLLAMA_MODEL)) - }) - }) -} - -fn test_config(db_path: &str) -> ServerConfig { +/// Build a `ServerConfig` from the operator's canonical config.toml, +/// pinned to a fresh temp `db_path` so tests don't touch the live DB. +fn test_server_config(db_path: &str) -> ServerConfig { + let cfg = common::load_e2e_config().expect("e2e config required"); ServerConfig { cache_mb: 64, db_path: db_path.to_string(), @@ -54,24 +39,12 @@ fn test_config(db_path: &str) -> ServerConfig { log_level: tracing::Level::WARN, allowed_paths: vec![], security: NamespaceSecurityConfig::default(), - embeddings: EmbeddingConfig { - required_dimension: LOCAL_OLLAMA_DIMENSION, - max_batch_chars: 32000, - max_batch_items: 16, - providers: vec![ProviderConfig { - name: "ollama-local".into(), - base_url: "http://localhost:11434".into(), - model: LOCAL_OLLAMA_MODEL.into(), - priority: 1, - endpoint: "/v1/embeddings".into(), - }], - reranker: Default::default(), - }, + embeddings: cfg.embeddings, hybrid: HybridConfig::default(), } } -/// Dispatch same payload through both transports, return (stdio_resp, sse_resp). +/// Dispatch the same payload through both transports. async fn dispatch_both( core: &rust_memex::McpCore, payload: &str, @@ -81,8 +54,8 @@ async fn dispatch_both( (stdio, sse) } -/// Strip the known transport-dependent `transport` field from health results -/// so we can compare the rest. +/// Strip the transport-dependent `transport` field from health results so +/// the rest can be compared. fn strip_transport_field(mut value: Value) -> Value { if let Some(content) = value["result"]["content"].as_array() && let Some(first) = content.first() @@ -90,8 +63,8 @@ fn strip_transport_field(mut value: Value) -> Value { && let Ok(mut parsed) = serde_json::from_str::(text) { parsed.as_object_mut().map(|o| o.remove("transport")); - let stripped_text = serde_json::to_string(&parsed).unwrap(); - value["result"]["content"][0]["text"] = json!(stripped_text); + let stripped = serde_json::to_string(&parsed).unwrap(); + value["result"]["content"][0]["text"] = json!(stripped); } value } @@ -101,16 +74,10 @@ fn strip_transport_field(mut value: Value) -> Value { // ============================================================================= #[tokio::test] -#[ignore] async fn parity_initialize_identical_across_transports() { - if !ollama_available().await { - eprintln!("SKIP: Ollama unavailable"); - return; - } - let tmp = TempDir::new().unwrap(); let db_path = tmp.path().join("lance"); - let config = test_config(db_path.to_str().unwrap()); + let config = test_server_config(db_path.to_str().unwrap()); let core = build_mcp_core(config).await.expect("build_mcp_core"); let payload = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#; @@ -130,16 +97,10 @@ async fn parity_initialize_identical_across_transports() { } #[tokio::test] -#[ignore] async fn parity_tools_list_identical_across_transports() { - if !ollama_available().await { - eprintln!("SKIP: Ollama unavailable"); - return; - } - let tmp = TempDir::new().unwrap(); let db_path = tmp.path().join("lance"); - let config = test_config(db_path.to_str().unwrap()); + let config = test_server_config(db_path.to_str().unwrap()); let core = build_mcp_core(config).await.expect("build_mcp_core"); let payload = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#; @@ -154,20 +115,31 @@ async fn parity_tools_list_identical_across_transports() { ); let tools = stdio["result"]["tools"].as_array().expect("tools array"); - assert_eq!(tools.len(), 14, "must expose all 14 tools"); + assert!( + !tools.is_empty(), + "tools/list must expose at least one tool" + ); + eprintln!("tools/list exposes {} tools", tools.len()); + // Schema invariant: every tool has a string `name` and `description`. + // This is robust to additions/removals (no magic count to drift) but + // still catches malformed entries. + for (i, tool) in tools.iter().enumerate() { + assert!( + tool["name"].is_string(), + "tool[{i}] missing string name: {tool}" + ); + assert!( + tool["description"].is_string(), + "tool[{i}] missing string description: {tool}" + ); + } } #[tokio::test] -#[ignore] async fn parity_health_tool_structurally_equivalent() { - if !ollama_available().await { - eprintln!("SKIP: Ollama unavailable"); - return; - } - let tmp = TempDir::new().unwrap(); let db_path = tmp.path().join("lance"); - let config = test_config(db_path.to_str().unwrap()); + let config = test_server_config(db_path.to_str().unwrap()); let core = build_mcp_core(config).await.expect("build_mcp_core"); let payload = r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"health","arguments":{}}}"#; @@ -176,7 +148,6 @@ async fn parity_health_tool_structurally_equivalent() { let stdio = stdio.expect("stdio must respond to health"); let sse = sse.expect("sse must respond to health"); - // Responses differ ONLY in the transport field. Strip it and compare. let stdio_stripped = strip_transport_field(stdio.clone()); let sse_stripped = strip_transport_field(sse.clone()); assert_eq!( @@ -184,7 +155,6 @@ async fn parity_health_tool_structurally_equivalent() { "health responses must match after stripping transport field" ); - // Verify the documented transport-dependent field let stdio_text: Value = serde_json::from_str(stdio["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); let sse_text: Value = @@ -201,16 +171,10 @@ async fn parity_health_tool_structurally_equivalent() { } #[tokio::test] -#[ignore] async fn parity_namespace_security_status_identical() { - if !ollama_available().await { - eprintln!("SKIP: Ollama unavailable"); - return; - } - let tmp = TempDir::new().unwrap(); let db_path = tmp.path().join("lance"); - let config = test_config(db_path.to_str().unwrap()); + let config = test_server_config(db_path.to_str().unwrap()); let core = build_mcp_core(config).await.expect("build_mcp_core"); let payload = r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"namespace_security_status","arguments":{}}}"#; @@ -225,16 +189,10 @@ async fn parity_namespace_security_status_identical() { } #[tokio::test] -#[ignore] async fn parity_resources_list_rejected_until_resources_exist() { - if !ollama_available().await { - eprintln!("SKIP: Ollama unavailable"); - return; - } - let tmp = TempDir::new().unwrap(); let db_path = tmp.path().join("lance"); - let config = test_config(db_path.to_str().unwrap()); + let config = test_server_config(db_path.to_str().unwrap()); let core = build_mcp_core(config).await.expect("build_mcp_core"); let payload = r#"{"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}"#; @@ -251,16 +209,10 @@ async fn parity_resources_list_rejected_until_resources_exist() { } #[tokio::test] -#[ignore] async fn parity_unknown_tool_error_identical() { - if !ollama_available().await { - eprintln!("SKIP: Ollama unavailable"); - return; - } - let tmp = TempDir::new().unwrap(); let db_path = tmp.path().join("lance"); - let config = test_config(db_path.to_str().unwrap()); + let config = test_server_config(db_path.to_str().unwrap()); let core = build_mcp_core(config).await.expect("build_mcp_core"); let payload = r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"nonexistent_tool","arguments":{}}}"#; @@ -273,16 +225,10 @@ async fn parity_unknown_tool_error_identical() { } #[tokio::test] -#[ignore] async fn parity_invalid_json_error_identical() { - if !ollama_available().await { - eprintln!("SKIP: Ollama unavailable"); - return; - } - let tmp = TempDir::new().unwrap(); let db_path = tmp.path().join("lance"); - let config = test_config(db_path.to_str().unwrap()); + let config = test_server_config(db_path.to_str().unwrap()); let core = build_mcp_core(config).await.expect("build_mcp_core"); let payload = r#"{not valid json"#; @@ -298,16 +244,10 @@ async fn parity_invalid_json_error_identical() { } #[tokio::test] -#[ignore] async fn parity_missing_id_error_identical() { - if !ollama_available().await { - eprintln!("SKIP: Ollama unavailable"); - return; - } - let tmp = TempDir::new().unwrap(); let db_path = tmp.path().join("lance"); - let config = test_config(db_path.to_str().unwrap()); + let config = test_server_config(db_path.to_str().unwrap()); let core = build_mcp_core(config).await.expect("build_mcp_core"); let payload = r#"{"jsonrpc":"2.0","method":"initialize","params":{}}"#; @@ -320,19 +260,12 @@ async fn parity_missing_id_error_identical() { } #[tokio::test] -#[ignore] async fn parity_notification_silent_on_both_transports() { - if !ollama_available().await { - eprintln!("SKIP: Ollama unavailable"); - return; - } - let tmp = TempDir::new().unwrap(); let db_path = tmp.path().join("lance"); - let config = test_config(db_path.to_str().unwrap()); + let config = test_server_config(db_path.to_str().unwrap()); let core = build_mcp_core(config).await.expect("build_mcp_core"); - // Notifications must return None on both transports (no response per JSON-RPC spec) let payload = r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#; let (stdio, sse) = dispatch_both(&core, payload).await; diff --git a/docs/ARCHITECTURE_ACTUAL.md b/docs/ARCHITECTURE_ACTUAL.md deleted file mode 100644 index b407988..0000000 --- a/docs/ARCHITECTURE_ACTUAL.md +++ /dev/null @@ -1,253 +0,0 @@ -# Memex Architecture - ACTUAL (Stan Faktyczny) - -```mermaid -flowchart TB - subgraph ENTRY["Entry Points"] - MCP["MCP Tools
(stdio JSON-RPC)"] - HTTP["HTTP API
(REST + SSE)"] - CLI["CLI
(commands)"] - end - - subgraph VALIDATION["Security Layer"] - PATH["Path Validation
• expand ~
• detect ..
• canonicalize
• whitelist check"] - TOKEN["Namespace Tokens
• create/revoke
• per-namespace auth"] - end - - subgraph EXTRACTION["Content Extraction"] - TXT["Plain Text (.txt)"] - PDF["PDF (.pdf)
pdf_extract"] - JSON_SMART["Smart JSON Detection
• Claude.ai export
• ChatGPT export
• Session essence
• Generic array"] - MD["Markdown (.md)
section extraction"] - CODE["Code (.rs/.py/.js)
❌ NO semantic chunking
treated as plain text"] - end - - subgraph DEDUP["Deduplication"] - HASH["SHA256 Hash
content_hash"] - CHECK["Storage Check
has_content_hash()"] - SKIP["Skip if EXISTS"] - end - - subgraph SLICING["Slicing Strategy"] - FLAT["FLAT MODE
512-char chunks
128-char overlap"] - ONION["ONION MODE (default)
4 hierarchical layers:
OUTER → MIDDLE → INNER → CORE"] - FAST["ONION-FAST
2 layers only
OUTER ↔ CORE"] - end - - subgraph EMBEDDING["Embedding Generation"] - MLX_ACTUAL["MLX Embedder
❌ Port: 12345 (not 8765)
Model: Qwen3-Embedding-8B-4bit-DWQ
Dims: 4096"] - OLLAMA["Ollama (fallback)
Port: 11434
qwen3-embedding:8b"] - BATCH["Smart Batching
max 64 items
max 128K chars"] - NO_VALIDATE["❌ NO Dimension Validation
Silent corruption possible"] - RETRY["Retry with Backoff
1s → 30s"] - end - - subgraph STORAGE["LanceDB Storage"] - RAMDISK["RAM Disk
/Volumes/MemexRAM
50GB HFS+"] - LANCE["LanceDB
~28GB vectors"] - SCHEMA["Schema v3
• id, namespace
• vector[4096]
• layer, parent_id
• content_hash"] - NO_ATOMIC["❌ NO Atomic Writes
Partial failures = ghost docs"] - end - - subgraph SEARCH["Search Pipeline"] - ROUTER["Query Router
auto-detect mode"] - VECTOR["Vector Search
ANN via IVF-HNSW"] - BM25["BM25 Full-Text"] - HYBRID["Hybrid Fusion
RRF scoring"] - NO_RERANK["❌ Reranker Optional
Falls back to cosine"] - end - - subgraph LAUNCHD["LaunchD Services"] - LD_RAMDISK["ai.libraxis.memex-ramdisk
Create 50GB RAM disk"] - LD_MLX_ACTUAL["ai.libraxis.mlx-embedding
❌ Port 12345 (not 8765)
WorkDir: vista-brain/scripts"] - LD_MEMEX["ai.libraxis.rust-memex
Port 8997 server
Uses RAM disk"] - LD_SNAPSHOT["ai.libraxis.memex-snapshot
Periodic sync"] - end - - subgraph E2E_TESTS["E2E Tests"] - NO_TEST_INDEX["❌ MISSING
test_index_search"] - NO_TEST_MCP["❌ MISSING
test_mcp_tools"] - NO_TEST_HTTP["❌ MISSING
test_http_api"] - NO_TEST_DEDUP["❌ MISSING
test_deduplication"] - end - - subgraph CONFIG["Configuration"] - NO_CONFIG["❌ NO config.toml
Uses env vars + hardcoded defaults"] - NO_MLX_FOLDER["❌ ~/.ai-memories/mlx-embeddings/
Contains unrelated project"] - end - - %% Flow connections - ENTRY --> VALIDATION - VALIDATION --> EXTRACTION - EXTRACTION --> DEDUP - DEDUP -->|NEW| SLICING - DEDUP -->|EXISTS| SKIP - SLICING --> EMBEDDING - EMBEDDING --> STORAGE - - %% Search flow - ENTRY --> SEARCH - SEARCH --> STORAGE - - %% LaunchD flow - LD_RAMDISK -->|creates| RAMDISK - LD_MLX_ACTUAL -->|provides| MLX_ACTUAL - LD_MEMEX -->|uses| RAMDISK - LD_MEMEX -.->|should call| MLX_ACTUAL - LD_SNAPSHOT -->|syncs| RAMDISK - - %% Styling - Green = works, Red = broken/missing, Yellow = partial - classDef works fill:#2d5a2d,stroke:#4a4,color:#fff - classDef broken fill:#5a2d2d,stroke:#a44,color:#fff - classDef partial fill:#5a5a2d,stroke:#aa4,color:#fff - classDef missing fill:#3d3d3d,stroke:#666,color:#888 - - class PATH,TOKEN,TXT,PDF,JSON_SMART,MD,HASH,CHECK,SKIP,FLAT,ONION,FAST,BATCH,RETRY,RAMDISK,LANCE,SCHEMA,ROUTER,VECTOR,BM25,HYBRID,LD_RAMDISK,LD_MEMEX,LD_SNAPSHOT works - class NO_VALIDATE,NO_ATOMIC,NO_RERANK,MLX_ACTUAL,LD_MLX_ACTUAL,NO_CONFIG,NO_MLX_FOLDER broken - class CODE,OLLAMA partial - class NO_TEST_INDEX,NO_TEST_MCP,NO_TEST_HTTP,NO_TEST_DEDUP missing -``` - -## Status: Co działa, co nie - -### ✅ DZIAŁA (zielone) -| Komponent | Status | Uwagi | -|-----------|--------|-------| -| Path Validation | ✅ | Pełna walidacja traversal | -| Namespace Tokens | ✅ | create/revoke/verify | -| Plain Text extraction | ✅ | UTF-8 read | -| PDF extraction | ✅ | pdf_extract crate | -| Smart JSON detection | ✅ | Claude/ChatGPT/Session | -| Markdown extraction | ✅ | Section-aware | -| Deduplication | ✅ | SHA256 + storage check | -| Flat/Onion/Fast slicing | ✅ | Wszystkie 3 tryby | -| Smart Batching | ✅ | 64 items / 128K chars | -| Retry with Backoff | ✅ | 1s → 30s | -| RAM Disk | ✅ | 50GB /Volumes/MemexRAM | -| LanceDB | ✅ | 28GB, schema v3 | -| Query Router | ✅ | auto-detect | -| Vector/BM25/Hybrid search | ✅ | Wszystkie tryby | -| LaunchD services | ✅ | ramdisk, memex, snapshot | - -### ❌ NIE DZIAŁA / NIE SPIĘTE (czerwone) -| Komponent | Problem | Impact | -|-----------|---------|--------| -| **MLX Port w kodzie** | Kod domyślnie 12345, serwer na **8765** | Config mismatch | -| **Atomic Writes** | BRAK - ghost docs przy crash | **HIGH** | -| **Reranker** | Optional, fallback to cosine | Słabsze wyniki | -| **config.toml** | BRAK - hardcoded defaults | Trudne zarządzanie | - -### ✅ NAPRAWIONE (w tej sesji) -| Komponent | Status | Uwagi | -|-----------|--------|-------| -| **Dimension Validation** | ✅ DODANE | `test_dimension()` w `EmbeddingClient::new()` | -| **E2E Tests** | ✅ DODANE | `tests/e2e_pipeline.rs` - 5 testów | -| **TextIntegrityMetrics** | ✅ DODANE | >90% threshold, audit command | -| **DimensionAdapter** | ✅ DODANE | Cross-dim search 1024/2048/4096 | -| **Audit/Purge commands** | ✅ DODANE | `rust-memex audit`, `purge-quality` | - -### ⚠️ CZĘŚCIOWE (żółte) -| Komponent | Status | Uwagi | -|-----------|--------|-------| -| Code extraction | ⚠️ | Traktowane jako plain text, brak AST | -| Ollama | ⚠️ | Działa jako fallback, ale to nie docelowy design | - -### ❌ CAŁKOWICIE BRAKUJE (szare) -| Komponent | Status | -|-----------|--------| -| E2E test: index → search | ❌ BRAK | -| E2E test: MCP tools | ❌ BRAK | -| E2E test: HTTP API | ❌ BRAK | -| E2E test: deduplication | ❌ BRAK | - ---- - -## Szczegóły LaunchD Services - -### Aktualny stan usług: -``` -PID STATUS SERVICE -10721 -15 ai.libraxis.rust-memex ← działa, port 8997 -46656 137 ai.libraxis.mlx-embedding ← działa, port 12345 (!) -46670 137 ai.libraxis.mlx-reranker ← działa -46743 1 ai.libraxis.mlx-batch-server -- 0 ai.libraxis.memex-snapshot -- 1 ai.libraxis.mlx-batch-runner -``` - -### ai.libraxis.mlx-embedding -``` -Port: 12345 (❌ powinien być 8765) -WorkDir: vista-brain/scripts/ (❌ powinien być ~/.ai-memories/mlx-embeddings/) -Script: mlx_embedding_server.py -Model: Qwen3-Embedding-8B-4bit-DWQ -Dims: 4096 ✅ -``` - -### ai.libraxis.rust-memex -``` -Port: 8997 ✅ -DB Path: /Volumes/MemexRAM/lancedb ✅ -Mode: --http-only ✅ -PathState: /Volumes/MemexRAM/lancedb (czeka na RAM disk) ✅ -``` - -### ai.libraxis.memex-ramdisk -``` -Size: 50GB (104857600 sectors) -Mount: /Volumes/MemexRAM ✅ -Source: ~/.ai-memories/lancedb -Sync: rsync -a ✅ -``` - ---- - -## Krytyczne luki do naprawy - -### 1. CRITICAL: Dimension Validation -```rust -// BRAK w embeddings/mod.rs -// Jeśli embedder zwróci 1024-dim: -// → Silent write to LanceDB -// → Cała baza corrupted -// → Brak recovery -``` - -### 2. HIGH: Atomic Batch Writes -```rust -// BRAK w storage/mod.rs -// Jeśli crash w połowie batch: -// → Ghost documents -// → Dedup nie złapie (inny hash?) -// → Brak rollback -``` - -### 3. MEDIUM: Port Mismatch -``` -OBIECANE: MLX embedder na 8765 -FAKTYCZNE: MLX embedder na 12345 - -rust-memex używa provider cascade: -1. Ollama localhost:11434 -2. Fallback dragon:12345 - -Więc działa, ale przez Ollama, nie przez dedykowany MLX! -``` - -### 4. MEDIUM: Missing Config -``` -OBIECANE: ~/.ai-memories/config.toml -FAKTYCZNE: Brak pliku - -Wszystko przez env vars lub hardcoded defaults. -Trudne do zarządzania na wielu maszynach. -``` - ---- - -## Propozycja naprawy (kolejność priorytetów) - -1. **[CRITICAL]** Dodać dimension validation w `EmbeddingClient::new()` -2. **[HIGH]** Implementować batch transaction z rollback -3. **[MEDIUM]** Zmienić port MLX na 8765 lub zaktualizować config memex -4. **[MEDIUM]** Stworzyć `~/.ai-memories/config.toml` z pełną konfiguracją -5. **[LOW]** Napisać testy E2E diff --git a/docs/ARCHITECTURE_COMPARISON.md b/docs/ARCHITECTURE_COMPARISON.md deleted file mode 100644 index 1e34397..0000000 --- a/docs/ARCHITECTURE_COMPARISON.md +++ /dev/null @@ -1,230 +0,0 @@ -# Memex Architecture Comparison: PROMISED vs ACTUAL - -## Quick Status - -``` -╔═══════════════════════════════════════════════════════════════════════════╗ -║ OVERALL STATUS: ~85% Complete ║ -╠═══════════════════════════════════════════════════════════════════════════╣ -║ ✅ Working: 20 components ║ -║ ✅ Fixed: 5 components (in this session) ║ -║ ❌ Broken: 2 components (1 HIGH) ║ -║ ⚠️ Partial: 2 components ║ -║ ⚠️ Config: Port mismatch (works via Ollama fallback) ║ -╚═══════════════════════════════════════════════════════════════════════════╝ -``` - ---- - -## Side-by-Side Comparison - -| Component | PROMISED | ACTUAL | Status | -|-----------|----------|--------|--------| -| **Entry Points** | -| MCP Tools | JSON-RPC over stdio | JSON-RPC over stdio | ✅ | -| HTTP API | REST + SSE on 8997 | REST + SSE on 8997 | ✅ | -| CLI | index/search/optimize | index/search/optimize | ✅ | -| **Security** | -| Path Validation | expand ~ / detect .. / canonicalize | expand ~ / detect .. / canonicalize | ✅ | -| Namespace Tokens | create/revoke/verify | create/revoke/verify | ✅ | -| **Extraction** | -| Plain Text | UTF-8 read | UTF-8 read | ✅ | -| PDF | pdf_extract crate | pdf_extract crate | ✅ | -| JSON (smart) | Claude/ChatGPT/Session detection | Claude/ChatGPT/Session detection | ✅ | -| Markdown | Section extraction | Section extraction | ✅ | -| Code | Semantic chunking (AST) | Plain text only | ⚠️ Partial | -| **Deduplication** | -| Hash Algorithm | SHA256 | SHA256 | ✅ | -| Storage Check | has_content_hash() | has_content_hash() | ✅ | -| **Slicing** | -| Flat Mode | 512-char / 128 overlap | 512-char / 128 overlap | ✅ | -| Onion Mode | 4 layers (outer→core) | 4 layers (outer→core) | ✅ | -| Onion-Fast | 2 layers | 2 layers | ✅ | -| **Embedding** | -| Embedder Port | **8765** | **12345** | ❌ Mismatch | -| Embedder Location | `~/.ai-memories/mlx-embeddings/` | `vista-brain/scripts/` | ❌ Wrong | -| Model | Qwen3-Embedding-8B | Qwen3-Embedding-8B-4bit-DWQ | ✅ | -| Dimensions | 4096 | 4096 | ✅ | -| **Dimension Validation** | **Fail fast on mismatch** | **✅ test_dimension() in EmbeddingClient::new()** | ✅ FIXED | -| **DimensionAdapter** | Cross-dim 1024/2048/4096 | **✅ expand/contract adapters** | ✅ FIXED | -| Batching | 64 items / 128K chars | 64 items / 128K chars | ✅ | -| Retry | Exponential backoff | Exponential backoff | ✅ | -| Fallback | Dragon remote | Ollama (unintended) | ⚠️ Partial | -| **Storage** | -| RAM Disk | 50GB /Volumes/MemexRAM | 50GB /Volumes/MemexRAM | ✅ | -| LanceDB Size | ~28GB | ~28GB | ✅ | -| Schema | v3 with content_hash | v3 with content_hash | ✅ | -| **Atomic Writes** | **Transaction rollback** | **NONE - ghost docs** | ❌ HIGH | -| **Search** | -| Query Router | auto-detect | auto-detect | ✅ | -| Vector Search | ANN via IVF-HNSW | ANN via IVF-HNSW | ✅ | -| BM25 | Full-text | Full-text | ✅ | -| Hybrid | RRF fusion | RRF fusion | ✅ | -| Reranker | Dedicated on 8766 | Optional (cosine fallback) | ❌ Weak | -| **LaunchD** | -| memex-ramdisk | Create 50GB | Create 50GB | ✅ | -| mlx-embedding | Port 8765 | Port 12345 | ❌ Mismatch | -| rust-memex | Port 8997, uses RAM | Port 8997, uses RAM | ✅ | -| memex-snapshot | Periodic sync | Periodic sync | ✅ | -| **Config** | -| Config File | `~/.ai-memories/config.toml` | **NONE** | ❌ Missing | -| **Quality Assurance** | -| TextIntegrityMetrics | >90% threshold | **✅ compute() + recommendation()** | ✅ FIXED | -| Audit command | Per-namespace check | **✅ rust-memex audit** | ✅ FIXED | -| Purge command | Remove low-quality | **✅ rust-memex purge-quality** | ✅ FIXED | -| **Testing** | -| E2E: pipeline | Required | **✅ tests/e2e_pipeline.rs (5 tests)** | ✅ FIXED | -| E2E: MCP tools | Required | **MISSING** | ❌ | -| E2E: HTTP API | Required | **MISSING** | ❌ | -| Unit tests | Required | ~20 tests | ✅ | - ---- - -## Visual Comparison - -### PROMISED Architecture -``` -┌─────────────────────────────────────────────────────────────────┐ -│ ENTRY POINTS │ -│ MCP (stdio) │ HTTP (8997) │ CLI │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ SECURITY + EXTRACTION │ -│ Path Validation │ Token Auth │ PDF/JSON/MD/Code extraction │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ DEDUPLICATION │ -│ SHA256 → check storage → skip if exists │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ SLICING (Flat/Onion/Fast) │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ MLX EMBEDDER (port 8765) │ -│ ~/.ai-memories/mlx-embeddings/ │ Qwen3-Embedding-8B │ 4096d │ -│ ✓ DIMENSION VALIDATION (fail fast) │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ LANCEDB (RAM DISK) │ -│ /Volumes/MemexRAM │ 50GB │ ~28GB vectors │ schema v3 │ -│ ✓ ATOMIC BATCH WRITES (transaction) │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ SEARCH │ -│ Router → Vector/BM25/Hybrid → Reranker (8766) → Results │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ E2E TESTS │ -│ index→search │ MCP tools │ HTTP API │ deduplication │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### ACTUAL Architecture (Updated) -``` -┌─────────────────────────────────────────────────────────────────┐ -│ ENTRY POINTS │ -│ MCP (stdio) │ HTTP (8997) │ CLI (rust-memex) │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ SECURITY + EXTRACTION │ -│ Path Validation │ Token Auth │ PDF/JSON/MD │ ⚠️Code=text │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ DEDUPLICATION │ -│ SHA256 → check storage → skip if exists │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ SLICING (Flat/Onion/Fast) │ -│ ✅ TextIntegrityMetrics (>90% threshold) │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ ⚠️ MLX EMBEDDER (port 12345, not 8765) │ -│ vista-brain/scripts/ │ Qwen3-Embedding-8B-4bit │ 4096d │ -│ ✅ DIMENSION VALIDATION (test_dimension()) │ -│ ✅ DimensionAdapter (cross-dim 1024/2048/4096) │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ LANCEDB (RAM DISK) │ -│ /Volumes/MemexRAM │ 50GB │ ~28GB vectors │ schema v3 │ -│ ❌ NO ATOMIC WRITES (ghost docs on crash) │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ SEARCH │ -│ Router → Vector/BM25/Hybrid → ⚠️ cosine fallback → Results │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ ✅ QUALITY ASSURANCE │ -│ ✅ E2E pipeline │ ✅ audit cmd │ ✅ purge cmd │ ❌ HTTP tests │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Priority Fix List - -### ✅ FIXED (This Session) -1. ~~**Dimension Validation**~~ → ✅ `test_dimension()` in `EmbeddingClient::new()` -2. ~~**E2E Tests**~~ → ✅ `tests/e2e_pipeline.rs` (5 tests) -3. ~~**Quality Metrics**~~ → ✅ `TextIntegrityMetrics` with >90% threshold -4. ~~**Cross-dim Search**~~ → ✅ `DimensionAdapter` (1024/2048/4096) -5. ~~**Audit/Purge**~~ → ✅ `rust-memex audit` + `purge-quality` commands - -### 🟠 HIGH (Data Integrity Risk) -1. **Atomic Batch Writes** - Implement transaction wrapper - ```rust - // Wrap batch operations in transaction - storage.begin_transaction()?; - for doc in batch { - storage.add(doc)?; - } - storage.commit()?; // or rollback on error - ``` - -### 🟡 MEDIUM (Config/Port Mismatch) -2. **MLX Port** - Change launch agent to port 8765 OR update memex config -3. **Config File** - Create `~/.ai-memories/config.toml` with all settings - -### 🟢 LOW (Nice to Have) -4. **Code Semantic Chunking** - Add AST-based chunking for .rs/.py/.js -5. **Reranker Integration** - Make reranker non-optional -6. **HTTP API Tests** - Add E2E tests for REST endpoints - ---- - -## Files Created - -1. `docs/ARCHITECTURE_PROMISED.md` - Docelowa architektura (Mermaid) -2. `docs/ARCHITECTURE_ACTUAL.md` - Stan faktyczny (Mermaid + czerwone krzyże) -3. `docs/ARCHITECTURE_COMPARISON.md` - Porównanie side-by-side (ten plik) - ---- - -*Vibecrafted with AI Agents by Vetcoders (c)2024-2026 Loctree* diff --git a/docs/ARCHITECTURE_PROMISED.md b/docs/ARCHITECTURE_PROMISED.md deleted file mode 100644 index 9b48eeb..0000000 --- a/docs/ARCHITECTURE_PROMISED.md +++ /dev/null @@ -1,127 +0,0 @@ -# Memex Architecture - PROMISED (Docelowa) - -```mermaid -flowchart TB - subgraph ENTRY["Entry Points"] - MCP["MCP Tools
(stdio JSON-RPC)"] - HTTP["HTTP API
(REST + SSE)"] - CLI["CLI
(commands)"] - end - - subgraph VALIDATION["Security Layer"] - PATH["Path Validation
• expand ~
• detect ..
• canonicalize
• whitelist check"] - TOKEN["Namespace Tokens
• create/revoke
• per-namespace auth"] - end - - subgraph EXTRACTION["Content Extraction"] - TXT["Plain Text (.txt)"] - PDF["PDF (.pdf)
pdf_extract"] - JSON_SMART["Smart JSON Detection
• Claude.ai export
• ChatGPT export
• Session essence
• Generic array"] - MD["Markdown (.md)
section extraction"] - CODE["Code (.rs/.py/.js)
semantic chunking"] - end - - subgraph DEDUP["Deduplication"] - HASH["SHA256 Hash
content_hash"] - CHECK["Storage Check
has_content_hash()"] - SKIP["Skip if EXISTS"] - end - - subgraph SLICING["Slicing Strategy"] - FLAT["FLAT MODE
512-char chunks
128-char overlap"] - ONION["ONION MODE (default)
4 hierarchical layers:
OUTER → MIDDLE → INNER → CORE"] - FAST["ONION-FAST
2 layers only
OUTER ↔ CORE"] - end - - subgraph EMBEDDING["Embedding Generation"] - MLX["MLX Native Embedder
Port: 8765
Model: Qwen3-Embedding-8B
Dims: 4096"] - BATCH["Smart Batching
max 64 items
max 128K chars"] - VALIDATE["Dimension Validation
FAIL FAST if mismatch"] - RETRY["Retry with Backoff
1s → 30s"] - end - - subgraph STORAGE["LanceDB Storage"] - RAMDISK["RAM Disk
/Volumes/MemexRAM
50GB HFS+"] - LANCE["LanceDB
~28GB vectors"] - SCHEMA["Schema v3
• id, namespace
• vector[4096]
• layer, parent_id
• content_hash"] - ATOMIC["Atomic Batch Writes
Transaction rollback"] - end - - subgraph SEARCH["Search Pipeline"] - ROUTER["Query Router
auto-detect mode"] - VECTOR["Vector Search
ANN via IVF-HNSW"] - BM25["BM25 Full-Text"] - HYBRID["Hybrid Fusion
RRF scoring"] - RERANK["Reranker
Port: 8766
cross-encoder"] - end - - subgraph LAUNCHD["LaunchD Services"] - LD_RAMDISK["ai.libraxis.memex-ramdisk
Create 50GB RAM disk"] - LD_MLX["ai.libraxis.mlx-embedding
Port 8765 embedder"] - LD_MEMEX["ai.libraxis.rust-memex
Port 8997 server"] - LD_SNAPSHOT["ai.libraxis.memex-snapshot
Periodic sync to disk"] - end - - subgraph E2E_TESTS["E2E Tests"] - TEST_INDEX["test_index_search
File → Vector → Search"] - TEST_MCP["test_mcp_tools
JSON-RPC handlers"] - TEST_HTTP["test_http_api
REST endpoints"] - TEST_DEDUP["test_deduplication
Hash collision handling"] - end - - %% Flow connections - ENTRY --> VALIDATION - VALIDATION --> EXTRACTION - EXTRACTION --> DEDUP - DEDUP -->|NEW| SLICING - DEDUP -->|EXISTS| SKIP - SLICING --> EMBEDDING - EMBEDDING --> STORAGE - - %% Search flow - ENTRY --> SEARCH - SEARCH --> STORAGE - - %% LaunchD flow - LD_RAMDISK -->|creates| RAMDISK - LD_MLX -->|provides| MLX - LD_MEMEX -->|uses| RAMDISK - LD_MEMEX -->|calls| MLX - LD_SNAPSHOT -->|syncs| RAMDISK - - %% Styling - classDef promised fill:#2d5a2d,stroke:#4a4,color:#fff - classDef critical fill:#5a2d2d,stroke:#a44,color:#fff - - class MLX,VALIDATE,ATOMIC,TEST_INDEX,TEST_MCP,TEST_HTTP,TEST_DEDUP,LD_MLX promised - class RAMDISK,LANCE,SCHEMA critical -``` - -## Kluczowe założenia docelowej architektury - -### 1. Embedding Layer -- **Dedykowany MLX embedder** na porcie **8765** -- Folder implementacji: `~/.ai-memories/mlx-embeddings/` -- Model: `Qwen3-Embedding-8B` (4096 dims) -- **NIE Ollama** - natywny MLX dla Dragona - -### 2. RAM Disk Architecture -- 50GB RAM disk `/Volumes/MemexRAM` -- LanceDB (~28GB) całkowicie w RAM -- Snapshot daemon - periodic sync to `~/.ai-memories/lancedb` -- PathState dependency - memex startuje dopiero gdy RAM disk gotowy - -### 3. Dimension Safety -- **Validation at startup** - sprawdzenie czy embedder zwraca 4096 dims -- **Fail fast** - crash jeśli mismatch, nie silent corruption - -### 4. Atomic Writes -- Transaction boundaries na batch writes -- Rollback przy partial failures -- No ghost documents - -### 5. E2E Test Coverage -- Full pipeline tests (index → embed → store → search → verify) -- MCP tool integration tests -- HTTP API endpoint tests -- Deduplication regression tests diff --git a/docs/LOCTREE_INTEGRATION_PROPOSAL.md b/docs/LOCTREE_INTEGRATION_PROPOSAL.md deleted file mode 100644 index 86cd384..0000000 --- a/docs/LOCTREE_INTEGRATION_PROPOSAL.md +++ /dev/null @@ -1,283 +0,0 @@ -# Loctree + rust-memex Integration Proposal - -**Date:** December 2025 -**Status:** Draft -**Authors:** rust-memex team - ---- - -## Executive Summary - -This proposal outlines a semantic search integration between **loctree** (AI-oriented static code analyzer) and **rust-memex** (MCP-based RAG/memory server). The integration would enable AI agents to perform semantic queries over code analysis reports, dead code detection, and structural insights. - ---- - -## Problem Statement - -AI agents (Claude, GPT, Copilot) working with codebases need: -1. **Contextual understanding** of code structure beyond simple grep -2. **Semantic search** over analysis findings (dead code, duplicates, cycles) -3. **Persistent memory** of analysis results across sessions -4. **Natural language queries** like "what symbols are unused in the storage module?" - -Currently, loctree generates excellent reports (SARIF, JSON, HTML), but they're not easily queryable by AI agents in natural language. - ---- - -## Proposed Solution - -### Architecture - -``` -┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ -│ loctree │────▶│ rust-memex │◀────│ AI Agent │ -│ (analyzer) │ │ (RAG + memory) │ │ (Claude, etc.) │ -└─────────────────┘ └──────────────────┘ └─────────────────┘ - │ │ - ▼ ▼ - analysis.json LanceDB - snapshot.json (embeddings) - report.sarif -``` - -### Data Flow - -1. **loctree** runs `loct scan` / `loct report` → generates JSON/SARIF -2. **rust-memex** ingests reports via new `loctree_index` tool -3. **AI Agent** queries via `rag_search` / `memory_search` tools -4. Results include relevant code findings with semantic ranking - ---- - -## New MCP Tools - -### `loctree_index` - -Index loctree analysis outputs into the vector store. - -```json -{ - "name": "loctree_index", - "description": "Index loctree analysis reports for semantic search", - "inputSchema": { - "type": "object", - "properties": { - "report_path": { - "type": "string", - "description": "Path to .loctree directory or specific JSON/SARIF file" - }, - "namespace": { - "type": "string", - "default": "loctree", - "description": "Namespace for organizing indexed data" - }, - "project_id": { - "type": "string", - "description": "Project identifier (e.g., git repo name)" - } - }, - "required": ["report_path"] - } -} -``` - -### `loctree_search` - -Semantic search over indexed loctree findings. - -```json -{ - "name": "loctree_search", - "description": "Search loctree findings semantically", - "inputSchema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Natural language query about code analysis" - }, - "finding_type": { - "type": "string", - "enum": ["dead_code", "duplicates", "cycles", "all"], - "default": "all" - }, - "project_id": { - "type": "string" - }, - "k": { - "type": "integer", - "default": 10 - } - }, - "required": ["query"] - } -} -``` - ---- - -## Embedding Strategy - -### What to Embed - -| Source File | Embeddable Units | Metadata | -|-------------|------------------|----------| -| `analysis.json` | Dead symbols, duplicate clusters, barrel exports | severity, paths, symbol names | -| `snapshot.json` | File summaries, import graphs, export lists | LOC, language, is_test | -| `report.sarif` | Individual findings/warnings | ruleId, level, location | - -### Chunking Strategy - -```rust -struct LoctreeFinding { - id: String, // e.g., "dead:FastEmbedder@src/embeddings/mod.rs" - finding_type: String, // "dead_code" | "duplicate" | "cycle" | "lint" - text: String, // Natural language description for embedding - metadata: LoctreeMetadata, -} - -struct LoctreeMetadata { - project_id: String, - git_commit: String, - file_path: String, - symbol_name: Option, - severity: String, - rule_id: Option, -} -``` - -### Example Embedded Text - -For a dead symbol finding: -``` -Dead code: symbol 'FastEmbedder' in src/embeddings/mod.rs is declared as public -but appears unused. This struct provides text embedding functionality using -the fastembed library. Consider removing if truly unused or adding pub(crate) -visibility. -``` - ---- - -## Query Examples - -| User Query | Expected Results | -|------------|------------------| -| "What code is unused in embeddings?" | FastEmbedder, MLXBridge findings | -| "Are there any circular dependencies?" | Cycle detection results | -| "Find duplicate function names" | Duplicate export clusters | -| "What did the last scan find?" | Summary of all findings | -| "Security issues in storage module" | Filtered SARIF results | - ---- - -## Implementation Phases - -### Phase 1: Basic Integration (MVP) -- [ ] Add `loctree_index` tool to rust-memex -- [ ] Parse `analysis.json` and `report.sarif` -- [ ] Store findings with namespace `loctree:{project_id}` -- [ ] Query via existing `rag_search` tool - -### Phase 2: Enhanced Search -- [ ] Add `loctree_search` with finding_type filter -- [ ] Implement project_id scoping -- [ ] Add git commit tracking for versioned queries - -### Phase 3: Real-time Integration -- [ ] Watch mode: auto-index on `loct scan` -- [ ] Incremental updates (diff-based indexing) -- [ ] Integration with loctree's `--json` output mode - -### Phase 4: Advanced Features -- [ ] Cross-project search (find similar patterns) -- [ ] Trend analysis (track findings over commits) -- [ ] AI-generated fix suggestions based on findings - ---- - -## Technical Considerations - -### Dependencies -- rust-memex already has: LanceDB, fastembed, serde_json -- No new deps needed for basic integration - -### Performance -- Typical loctree report: 50-100KB JSON → ~50-200 chunks -- Embedding time: <5 seconds per report -- Query latency: <100ms (LanceDB ANN search) - -### Storage -- ~1MB per project in LanceDB (embeddings + metadata) -- Retention policy: keep last N commits or time-based - ---- - -## Benefits for Loctree - -1. **AI Agent Ecosystem**: rust-memex is MCP-compatible → works with Claude, Cursor, etc. -2. **Semantic Queries**: Beyond regex, natural language understanding -3. **Memory Persistence**: Findings available across AI sessions -4. **Low Integration Effort**: Just output JSON, rust-memex handles the rest - ---- - -## Next Steps - -1. **Review** this proposal with Loctree team -2. **Prototype** `loctree_index` with existing analysis.json -3. **Define** exact JSON schema contract -4. **Implement** Phase 1 MVP -5. **Test** with real-world projects - ---- - -## Appendix: Sample Data Structures - -### analysis.json (relevant sections) - -```json -{ - "analysis": [{ - "aiViews": { - "deadSymbols": [ - { - "name": "FastEmbedder", - "paths": ["src/embeddings/mod.rs"], - "publicSurface": true - } - ], - "ciSummary": { - "duplicateClustersCount": 3, - "topClusters": [ - {"symbolName": "new", "size": 3, "severity": "medium"} - ] - } - } - }] -} -``` - -### report.sarif (relevant sections) - -```json -{ - "runs": [{ - "results": [ - { - "ruleId": "duplicate-export", - "level": "warning", - "message": {"text": "Duplicate export 'storage'"}, - "locations": [{"physicalLocation": {"artifactLocation": {"uri": "src/lib.rs"}}}] - } - ] - }] -} -``` - ---- - -## Contact - -For questions or collaboration: -- Repository: https://github.com/Loctree/rust-memex -- Branch: `rebranding-and-improvements` diff --git a/docs/PIPELINE_RESUME_PROGRESS_PLAN.md b/docs/PIPELINE_RESUME_PROGRESS_PLAN.md deleted file mode 100644 index c305702..0000000 --- a/docs/PIPELINE_RESUME_PROGRESS_PLAN.md +++ /dev/null @@ -1,271 +0,0 @@ -# Pipeline Resume + Progress Plan - -## Current State - -The async pipeline path already has the right broad shape: - -- reader -- chunker -- embedder -- storage - -It overlaps I/O, chunk creation, embedding, and writes through bounded channels in `src/rag/pipeline.rs`. - -What is still false today: - -- `--pipeline` disables `--progress` -- `--pipeline` disables `--resume` -- throughput is not adaptively governed based on real embedder / GPU conditions -- final stats exist, but there is no truthful runtime stream for operators - -Relevant code: - -- `src/rag/pipeline.rs` -- `src/bin/cli/maintenance.rs` -- `src/embeddings/mod.rs` - -## Better Shape - -Pipeline mode should become the canonical high-throughput path, not the "fast but blind" path. - -It should support: - -- live progress and ETA -- truthful resume after interruption -- adaptive throughput control based on observed embedder pressure -- preservation of current backpressure architecture - -## Design Principles - -### 1. Resume must be commit-based, not read-based - -Do not checkpoint: - -- file discovered -- file read -- chunks created -- chunks embedded - -Checkpoint only when a file is durably committed to storage. - -That is the only moment where resume becomes truthful. - -### 2. Progress must come from stage facts, not guesses - -Expose runtime snapshots based on real counters from each stage: - -- files discovered -- files read -- files skipped -- files committed -- chunks created -- chunks embedded -- chunks stored -- stage-local errors -- queue depth between stages -- rolling rate / ETA - -### 3. GPU control should be flow control, not fake hardware control - -Do not pretend we are scheduling Apple GPU cores directly. - -What we can truthfully control: - -- batch size -- max chars per batch -- number of concurrent embed requests -- channel depths / backpressure thresholds - -That is enough to "govern GPU usage" in practice. - -## Architecture Plan - -### 1. Add pipeline progress events - -Introduce a progress/event stream emitted from the pipeline coordinator and stages. - -Suggested shape: - -```rust -enum PipelineEvent { - FileRead { path: PathBuf }, - FileSkipped { path: PathBuf, reason: String }, - ChunksCreated { path: PathBuf, count: usize }, - ChunksEmbedded { path: PathBuf, count: usize }, - FileCommitted { path: PathBuf, chunk_count: usize }, - Error { path: Option, stage: &'static str, message: String }, - Snapshot(PipelineSnapshot), -} -``` - -This keeps CLI and future TUI consumers decoupled from implementation details. - -### 2. Add stage-aware snapshot model - -Define a `PipelineSnapshot` that carries runtime state: - -- total files -- files read -- files skipped -- files committed -- chunks created -- chunks embedded -- chunks stored -- queue depths -- current embed batch size -- rolling files/sec -- rolling chunks/sec -- ETA -- elapsed -- errors - -CLI `--progress` should render from this stream rather than only printing final totals. - -### 3. Preserve file boundaries through the pipeline - -Right now storage sees only batches of `EmbeddedChunk`. - -To make resume truthful, storage needs to know when all chunks for a given source file have been flushed successfully. - -Refactor the payload shape so file identity survives until commit time. - -For example: - -- wrap per-file chunks as a unit through the pipeline -- or annotate each embedded chunk batch with file completion markers - -The target is simple: - -- storage stage can emit `FileCommitted(path)` - -### 4. Add checkpoint support for pipeline mode - -Reuse the existing checkpoint concept from CLI maintenance, but move semantics to commit-based tracking. - -Checkpoint contents should include: - -- namespace -- db path -- committed file set -- last updated timestamp -- optional stats snapshot - -On startup with `--resume`: - -- load checkpoint -- filter already committed files before reader stage -- continue only with unfinished files - -On successful full completion: - -- remove checkpoint - -On partial failure: - -- preserve checkpoint - -### 5. Add progress rendering to CLI pipeline mode - -Replace today's warnings in `src/bin/cli/maintenance.rs` with real support: - -- interactive progress bar when terminal is interactive -- line-based snapshots when non-interactive - -The operator should see: - -- current file count -- chunk flow -- stage bottleneck -- effective embedding rate -- ETA - -### 6. Add adaptive throughput governor - -Add an optional governor that tunes embed workload based on runtime signals. - -Candidate inputs: - -- rolling embed latency -- batch failure / retry rate -- queue depth before embedder -- queue depth before storage -- macOS GPU utilization from `ioreg` -- GPU memory / driver memory from `IOAccelerator` - -Candidate outputs: - -- lower / raise `max_batch_items` -- lower / raise `max_batch_chars` -- lower / raise concurrent embed requests - -This should be conservative and monotonic: - -- increase slowly -- decrease quickly on pressure - -### 7. Keep manual override stronger than automation - -Adaptive mode should be optional. - -Operators should still be able to force: - -- fixed batching -- fixed concurrency -- governor off - -The system should help, not seize control. - -## Implementation Order - -### Phase 1: Visibility - -- add `PipelineEvent` -- add `PipelineSnapshot` -- wire live progress to CLI - -### Phase 2: Truthful resume - -- preserve file boundaries to storage -- emit `FileCommitted` -- add checkpointing for pipeline mode - -### Phase 3: Adaptive governor - -- add runtime telemetry inputs -- adapt batching / concurrency -- expose current governor decisions in progress output - -## Risks - -### 1. False resume semantics - -If checkpointing happens before durable storage completion, resume will lie and can silently skip unfinished work. - -### 2. Memory blow-up - -If file boundaries are preserved naively, large per-file buffers can explode RAM. - -Mitigation: - -- preserve file identity without forcing entire file payloads to stay buffered in memory longer than needed - -### 3. Overactive governor - -If adaptive control changes knobs too aggressively, throughput will oscillate. - -Mitigation: - -- rolling averages -- hysteresis -- clamp ranges -- cool-down intervals - -## Quick Win - -The smallest sharp move is: - -1. add live progress to pipeline mode -2. expose queue depths and embed rate -3. stop printing "progress unsupported" - -That alone turns pipeline mode from a black box into an operational path worth trusting. diff --git a/docs/TUI_DYNAMICS_PLAN.md b/docs/TUI_DYNAMICS_PLAN.md deleted file mode 100644 index 6f5ba5f..0000000 --- a/docs/TUI_DYNAMICS_PLAN.md +++ /dev/null @@ -1,28 +0,0 @@ -# TUI Dynamics Implementation Plan - -## Problem Statement -The TUI currently relies on a fragile heuristic (`infer_embedding_dimension` in `src/embeddings/mod.rs`) that hardcodes model names to embedding dimensions (e.g., `2560` for Qwen 4B, `4096` for 8B). This is factually incorrect because Qwen models often use MRL (Matryoshka Representation Learning), meaning an 8B model could be truncated to 2560 dimensions, or vice-versa. The TUI forces this false heuristic, resulting in confusing prints like `2560 if is_qwen => "Qwen3 Embedding 4B or MRL-truncated 8B (2560 dims)"` in `src/tui/detection.rs`. - -## Scope of Changes - -### 1. Remove Heuristics (`src/embeddings/mod.rs`) -- Remove the `infer_embedding_dimension` function. -- Remove fallbacks like `DEFAULT_REQUIRED_DIMENSION = 2560` that mask the need for actual dimension probing. - -### 2. Enforce Live Probing (`src/tui/app.rs`) -- `EmbedderState` must only trust dimensions obtained via a live probe (`DimensionTruth::Probed`) or explicitly provided by the user (`DimensionTruth::Manual`). -- Remove `DimensionTruth::Inferred` and `DimensionTruth::Default` from the state. -- Do not allow the configuration wizard to proceed or save unless the dimension has been definitively proven (probed) or manually set. - -### 3. Clean UI Explanations (`src/tui/detection.rs`) -- Simplify the `dimension_explanation` function. Instead of guessing the model variant based on the dimension, it should simply state the verified dimension. -- Remove the hardcoded text snippets like `"Qwen3 Embedding 4B or MRL-truncated 8B (2560 dims)"`. - -### 4. Require Strict Validation -- If a provider is offline, the user must input the dimension manually. The system should no longer "guess" the dimension based on the string name. - -## Migration Steps -1. Delete `infer_embedding_dimension` from `src/embeddings/mod.rs`. -2. Update `EmbedderState` in `src/tui/app.rs` to remove the removed `DimensionTruth` variants. -3. Update `apply_detected_provider` and `refresh_manual_dimension_state` in `src/tui/app.rs` to rely exclusively on `schedule_dimension_probe`. -4. Refactor `dimension_explanation` in `src/tui/detection.rs` to format dynamically derived dimension outputs cleanly. diff --git a/docs/TUI_INDEX_MONITOR_PLAN.md b/docs/TUI_INDEX_MONITOR_PLAN.md deleted file mode 100644 index e72c806..0000000 --- a/docs/TUI_INDEX_MONITOR_PLAN.md +++ /dev/null @@ -1,175 +0,0 @@ -# TUI Index Monitor Plan - -## Current State - -The existing TUI wizard already has a solid shell, but the indexing step is still too thin for real operations. - -- The indexing screen shows only basic progress. -- There is no live CPU/GPU/RAM telemetry. -- There is no runtime control over indexing speed. -- The TUI indexer path is effectively single-gear from the operator's point of view. - -Relevant code: - -- `src/tui/app.rs` -- `src/tui/ui.rs` -- `src/tui/indexer.rs` - -## Target Shape - -Turn the `Data Setup -> Indexing` step into a real operator dashboard inside the existing Crossterm/Ratatui TUI. - -It should provide: - -- live system monitor: CPU, RAM, `rust-memex`, embedder processes, GPU -- live indexing monitor: files processed, rate, ETA, skipped, failed, inflight, current file -- runtime controls: pause/resume and parallelism up/down -- honest telemetry on macOS, with best-effort GPU metrics via `ioreg` - -## Architecture Plan - -### 1. Extend indexing progress model - -Expand `IndexProgress` in `src/tui/indexer.rs` so it carries richer runtime state: - -- `processed` -- `total` -- `skipped` -- `failed` -- `inflight` -- `parallelism` -- `paused` -- `files_per_sec` -- `eta_seconds` -- `elapsed_seconds` -- `current_file` -- `complete` -- `error` - -This gives the UI one truthful state packet instead of forcing it to infer behavior. - -### 2. Add index control channel - -Add a control path from the TUI into the indexing task. - -Suggested command enum: - -```rust -enum IndexControl { - Pause, - Resume, - SetParallelism(usize), - Stop, -} -``` - -This will let the operator tune the job without restarting it. - -### 3. Rework TUI indexing runtime into a scheduler - -Replace the simple sequential loop in `start_indexing()` / `run_indexing()` with a bounded scheduler that: - -- tracks a work queue -- controls concurrent file tasks -- reacts to `IndexControl` -- updates progress snapshots continuously - -This is the core enabling step for the regulator. - -### 4. Add telemetry module - -Create a new module: - -- `src/tui/monitor.rs` - -It should sample: - -- system CPU load -- system memory usage / pressure proxy -- `rust-memex` process CPU and RSS -- embedder process aggregate CPU and RSS -- GPU utilization and memory on macOS via `ioreg` - -The GPU path should be best-effort and honest: - -- use `IOAccelerator` / `AGXAccelerator` -- parse `Device Utilization %` -- parse `In use system memory` -- fall back gracefully when metrics are unavailable - -### 5. Integrate monitor state into App - -Extend `App` in `src/tui/app.rs` with: - -- monitor state snapshot -- receiver for telemetry updates -- sender for index controls - -This should mirror the current `index_progress_rx` pattern so the event loop stays simple. - -### 6. Upgrade indexing screen into dashboard - -Rebuild the `DataSetupSubStep::Indexing` view in `src/tui/ui.rs` into a real dashboard. - -Recommended layout: - -- left pane: progress gauge, rate, ETA, current file, pause state, parallelism -- right pane: system CPU/RAM, `rust-memex`, embedder aggregate, GPU -- bottom strip: controls and recent status notes - -Use gauges and compact stat blocks rather than a long wall of text. - -### 7. Add keybindings - -During active indexing: - -- `Space` -> pause/resume -- `+` / `=` -> increase parallelism -- `-` -> decrease parallelism -- `s` -> stop after current inflight batch or request shutdown - -Footer help text should reflect the active control mode. - -### 8. Verification - -Before merging: - -- `cargo test` -- `cargo clippy -- -D warnings` - -Also verify manually in a real index run that: - -- progress updates stay smooth -- pause/resume works -- parallelism changes are visible in runtime behavior -- GPU fallback does not break on unsupported machines - -## Delivery Strategy - -### Phase 1 - -Ship the monitor first: - -- richer progress state -- telemetry sampler -- dashboard UI - -### Phase 2 - -Ship the regulator: - -- pause/resume -- live parallelism changes -- stop control - -This split gets visible value quickly without mixing UI polish and concurrency surgery into one blind jump. - -## Quick Win - -The smallest sharp move is: - -1. add `monitor.rs` -2. show CPU/RAM/GPU + rate/ETA on the indexing screen -3. keep control static for the first pass - -That already turns the TUI from a setup wizard into a useful operator console. diff --git a/docs/HTTP_API.md b/docs/en/HTTP_API.md similarity index 83% rename from docs/HTTP_API.md rename to docs/en/HTTP_API.md index 991d48e..3865028 100644 --- a/docs/HTTP_API.md +++ b/docs/en/HTTP_API.md @@ -42,6 +42,49 @@ Default dashboard URL: http://localhost:8987/ ``` +## Auth Model + +- Bearer is the canonical auth contract for API, SSE, and MCP routes. +- If `auth_mode = "all-routes"`, read routes also require auth. +- Optional dashboard OIDC adds browser sessions for dashboard-facing routes only: + - `/` + - `/api/*` + - `/search` + - `/cross-search` + - `/expand/*` + - `/parent/*` + - `/get/*` +- OIDC does not unlock MCP or agent-facing SSE routes. Those stay Bearer-only. + +Dashboard OIDC is configured in TOML or env: + +```toml +auth_token = "replace-me" + +[dashboard_oidc] +issuer_url = "https://issuer.example" +client_id = "rust-memex-dashboard" +client_secret = "optional-confidential-client-secret" +public_base_url = "https://memex.example.com" +scopes = ["openid", "profile", "email"] +``` + +Equivalent env vars: + +- `MEMEX_DASHBOARD_OIDC_ISSUER_URL` +- `MEMEX_DASHBOARD_OIDC_CLIENT_ID` +- `MEMEX_DASHBOARD_OIDC_CLIENT_SECRET` +- `MEMEX_DASHBOARD_OIDC_PUBLIC_BASE_URL` +- `MEMEX_DASHBOARD_OIDC_SCOPES` as a comma-separated list + +Public browser auth endpoints: + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/auth/login` | GET | Start OIDC authorization code flow | +| `/auth/callback` | GET | Complete OIDC login and set dashboard session cookie | +| `/auth/logout` | GET | Clear dashboard session cookie | + ## Canonical Discovery ### `GET /api/discovery` diff --git a/docs/MIGRATION.md b/docs/en/MIGRATION.md similarity index 100% rename from docs/MIGRATION.md rename to docs/en/MIGRATION.md diff --git a/docs/RELEASE.md b/docs/en/RELEASE.md similarity index 100% rename from docs/RELEASE.md rename to docs/en/RELEASE.md diff --git a/docs/index.html b/docs/index.html index d4cc2c6..c5b6811 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,29 +1,33 @@ - rust-memex | Shared memory for MCP agents - + - + - + -
@@ -48,8 +52,7 @@

One canonical memory surface for local MCP and multi-agent daemon mode.

Quick install @@ -165,5 +168,4 @@

What shipping now means.

- - \ No newline at end of file + diff --git a/docs/01_security.md b/docs/pl/01_security.md similarity index 100% rename from docs/01_security.md rename to docs/pl/01_security.md diff --git a/docs/02_configuration.md b/docs/pl/02_configuration.md similarity index 100% rename from docs/02_configuration.md rename to docs/pl/02_configuration.md diff --git a/docs/INDEXING_GUIDE.md b/docs/pl/INDEXING_GUIDE.md similarity index 100% rename from docs/INDEXING_GUIDE.md rename to docs/pl/INDEXING_GUIDE.md diff --git a/install.sh b/install.sh index 1edcf06..c008c36 100755 --- a/install.sh +++ b/install.sh @@ -210,7 +210,7 @@ main() { chmod +x "$INSTALL_DIR/$BINARY_NAME" install_compat_aliases "$INSTALL_DIR/$BINARY_NAME" success "Installed ${BINARY_NAME} to $INSTALL_DIR/$BINARY_NAME" - info "Legacy compatibility alias: $INSTALL_DIR/rmcp_memex" + info "Legacy compatibility alias: $INSTALL_DIR/rust_memex" installed_version=$("$INSTALL_DIR/$BINARY_NAME" --version 2>/dev/null || echo "unknown") info "Installed version: $installed_version" diff --git a/src/bin/cli/data.rs b/src/bin/cli/data.rs deleted file mode 100644 index 2012390..0000000 --- a/src/bin/cli/data.rs +++ /dev/null @@ -1,1274 +0,0 @@ -use anyhow::{Result, anyhow}; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value, json}; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::Mutex; - -use rust_memex::{ - EmbeddingClient, EmbeddingConfig, PreprocessingConfig, Preprocessor, RAGPipeline, SliceMode, - StorageManager, compute_content_hash, path_utils, -}; - -#[allow(dead_code)] -fn parse_features(raw: &str) -> Vec { - raw.split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect() -} - -/// Standard config discovery locations (in priority order) -#[allow(dead_code)] -const CONFIG_SEARCH_PATHS: &[&str] = &[ - "~/.rmcp-servers/rust-memex/config.toml", - "~/.config/rust-memex/config.toml", - "~/.rmcp_servers/rust_memex/config.toml", // legacy underscore path -]; - -/// Discover config file from standard locations -#[allow(dead_code)] -fn discover_config() -> Option { - // 1. Environment variable takes priority - if let Ok(path) = std::env::var("RUST_MEMEX_CONFIG") { - let expanded = shellexpand::tilde(&path).to_string(); - if std::path::Path::new(&expanded).exists() { - return Some(path); - } - } - - // 2. Check standard locations - for path in CONFIG_SEARCH_PATHS { - let expanded = shellexpand::tilde(path).to_string(); - if std::path::Path::new(&expanded).exists() { - return Some(path.to_string()); - } - } - - None -} - -#[allow(dead_code)] -fn load_file_config(path: &str) -> Result { - let (_canonical, contents) = path_utils::safe_read_to_string(path) - .map_err(|e| anyhow::anyhow!("Cannot load config '{}': {}", path, e))?; - toml::from_str(&contents).map_err(Into::into) -} - -/// Load config from explicit path or discover from standard locations -#[allow(dead_code)] -fn load_or_discover_config(explicit_path: Option<&str>) -> Result<(FileConfig, Option)> { - // Explicit path takes priority - if let Some(path) = explicit_path { - return Ok((load_file_config(path)?, Some(path.to_string()))); - } - - // Try to discover config - if let Some(discovered) = discover_config() { - return Ok((load_file_config(&discovered)?, Some(discovered))); - } - - // No config found - use defaults - Ok((FileConfig::default(), None)) -} - -use crate::cli::config::*; -/// JSONL export record structure -#[derive(Debug, Serialize, Deserialize)] -pub struct ExportRecord { - pub id: String, - pub text: String, - pub metadata: Value, - pub content_hash: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub embeddings: Option>, -} - -#[derive(Debug, Clone)] -struct ReprocessDocument { - canonical_id: String, - source_record_id: String, - text: String, - metadata: Value, - source_text_hash: String, - collapsed_records: usize, -} - -struct RebuildPlan { - namespace: String, - db_path: String, - source_label: String, - source_records: usize, - docs: Vec, - slice_mode: SliceMode, - preprocess: bool, - skip_existing: bool, - dry_run: bool, - parse_errors: usize, -} - -pub struct ReprocessConfig { - pub namespace: String, - pub input: PathBuf, - pub slice_mode: SliceMode, - pub preprocess: bool, - pub skip_existing: bool, - pub dry_run: bool, - pub db_path: String, -} - -pub struct ReindexConfig { - pub source_namespace: String, - pub target_namespace: String, - pub slice_mode: SliceMode, - pub preprocess: bool, - pub skip_existing: bool, - pub dry_run: bool, - pub db_path: String, -} - -pub fn default_reindexed_namespace(namespace: &str) -> String { - format!("{namespace}-reindexed") -} - -fn preferred_reprocess_id(record: &ExportRecord) -> String { - record - .metadata - .get("original_id") - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .or_else(|| { - record - .metadata - .get("doc_id") - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - }) - .map(ToOwned::to_owned) - .unwrap_or_else(|| record.id.clone()) -} - -fn reprocess_layer_rank(metadata: &Value) -> u8 { - match metadata.get("layer").and_then(Value::as_str) { - Some("core") => 4, - Some("inner") => 3, - Some("middle") => 2, - Some("outer") => 1, - _ => 0, - } -} - -fn should_replace_reprocess_candidate( - current: &ReprocessDocument, - candidate: &ReprocessDocument, -) -> bool { - let current_rank = (current.text.len(), reprocess_layer_rank(¤t.metadata)); - let candidate_rank = ( - candidate.text.len(), - reprocess_layer_rank(&candidate.metadata), - ); - candidate_rank > current_rank -} - -fn collapse_export_records(records: Vec) -> Vec { - let mut grouped: HashMap = HashMap::new(); - - for record in records { - let text = record.text.trim(); - if text.is_empty() { - continue; - } - - let candidate = ReprocessDocument { - canonical_id: preferred_reprocess_id(&record), - source_record_id: record.id, - text: text.to_string(), - metadata: record.metadata, - source_text_hash: compute_content_hash(text), - collapsed_records: 1, - }; - - match grouped.entry(candidate.canonical_id.clone()) { - std::collections::hash_map::Entry::Vacant(entry) => { - entry.insert(candidate); - } - std::collections::hash_map::Entry::Occupied(mut entry) => { - let total_records = entry.get().collapsed_records + 1; - if should_replace_reprocess_candidate(entry.get(), &candidate) { - let mut replacement = candidate; - replacement.collapsed_records = total_records; - entry.insert(replacement); - } else { - entry.get_mut().collapsed_records = total_records; - } - } - } - } - - let mut docs: Vec = grouped.into_values().collect(); - docs.sort_by(|left, right| left.canonical_id.cmp(&right.canonical_id)); - docs -} - -fn reprocess_slice_mode_name(slice_mode: SliceMode) -> &'static str { - match slice_mode { - SliceMode::Onion => "onion", - SliceMode::OnionFast => "onion-fast", - SliceMode::Flat => "flat", - } -} - -fn prepare_reprocess_metadata( - metadata: &Value, - source_record_id: &str, - source_text_hash: &str, - collapsed_records: usize, - slice_mode: SliceMode, - source_label: &str, - preprocess: bool, -) -> Value { - let mut map = match metadata.clone() { - Value::Object(map) => map, - _ => Map::new(), - }; - - for key in [ - "layer", - "parent_id", - "children_ids", - "original_id", - "slice_mode", - "content_hash", - ] { - map.remove(key); - } - - map.insert( - "slice_mode".to_string(), - json!(reprocess_slice_mode_name(slice_mode)), - ); - map.insert( - "reprocess_source_record_id".to_string(), - json!(source_record_id), - ); - map.insert("reprocess_source_hash".to_string(), json!(source_text_hash)); - map.insert( - "reprocess_collapsed_records".to_string(), - json!(collapsed_records), - ); - map.insert("reprocess_source".to_string(), json!(source_label)); - if preprocess { - map.insert("reprocess_preprocessed".to_string(), json!(true)); - } - - Value::Object(map) -} - -fn parse_export_records(content: &str) -> (Vec, usize) { - let mut parse_errors = 0usize; - let mut records = Vec::new(); - - for (line_num, line) in content.lines().enumerate() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - match serde_json::from_str::(trimmed) { - Ok(record) => records.push(record), - Err(err) => { - parse_errors += 1; - eprintln!(" Line {}: parse error - {}", line_num + 1, err); - } - } - } - - (records, parse_errors) -} - -async fn run_reprocess_documents( - plan: RebuildPlan, - embedding_config: &EmbeddingConfig, -) -> Result<()> { - let RebuildPlan { - namespace, - db_path, - source_label, - source_records, - docs, - slice_mode, - preprocess, - skip_existing, - dry_run, - parse_errors, - } = plan; - - if docs.is_empty() { - eprintln!("No non-empty documents found after collapsing export records"); - return Ok(()); - } - - let collapsed_records = source_records.saturating_sub(docs.len()); - - eprintln!( - "Reprocessing {} source records into {} canonical documents for namespace '{}'...", - source_records, - docs.len(), - namespace - ); - eprintln!(" Source: {}", source_label); - eprintln!(" Slice mode: {}", reprocess_slice_mode_name(slice_mode)); - eprintln!( - " Preprocess: {}", - if preprocess { "enabled" } else { "disabled" } - ); - eprintln!( - " Collapsed: {} duplicate slice records", - collapsed_records - ); - if parse_errors > 0 { - eprintln!(" Parse errors: {}", parse_errors); - } - - if dry_run { - eprintln!(); - eprintln!("Dry run only: no documents were written."); - return Ok(()); - } - - let storage = Arc::new(StorageManager::new_lance_only(&db_path).await?); - let embedding_client = Arc::new(Mutex::new(EmbeddingClient::new(embedding_config).await?)); - - { - let mut guard = embedding_client.lock().await; - guard.embed("healthcheck").await.map_err(|e| { - anyhow!( - "Embedding server preflight failed: {}. \ - Fix the embedding server before reprocessing to avoid partial data loss.", - e - ) - })?; - eprintln!(" Preflight: embedding server OK"); - } - - let rag = RAGPipeline::new(embedding_client, storage).await?; - let preprocessor = preprocess.then(|| Preprocessor::new(PreprocessingConfig::default())); - let min_length = PreprocessingConfig::default().min_content_length; - - let mut indexed = 0usize; - let mut replaced = 0usize; - let mut skipped_existing_count = 0usize; - let mut skipped_empty_input = 0usize; - let mut skipped_preprocess_short = 0usize; - let mut failed_ids: Vec = Vec::new(); - let total = docs.len(); - - for (idx, doc) in docs.iter().enumerate() { - let existing = rag.lookup_memory(&namespace, &doc.canonical_id).await?; - if let Some(existing_doc) = existing.as_ref() - && skip_existing - && existing_doc - .metadata - .get("reprocess_source_hash") - .and_then(Value::as_str) - == Some(doc.source_text_hash.as_str()) - { - skipped_existing_count += 1; - if (idx + 1) % 250 == 0 { - eprintln!( - " Progress: {}/{} (indexed:{} skipped:{})", - idx + 1, - total, - indexed, - skipped_existing_count - ); - } - continue; - } - - let text = if let Some(preprocessor) = &preprocessor { - preprocessor.extract_semantic_content(&doc.text) - } else { - doc.text.clone() - }; - - if text.trim().is_empty() { - skipped_empty_input += 1; - continue; - } - - if preprocess && text.trim().len() < min_length { - skipped_preprocess_short += 1; - continue; - } - - let metadata = prepare_reprocess_metadata( - &doc.metadata, - &doc.source_record_id, - &doc.source_text_hash, - doc.collapsed_records, - slice_mode, - &source_label, - preprocess, - ); - - if existing.is_some() { - replaced += 1; - } - - match rag - .memory_upsert(&namespace, doc.canonical_id.clone(), text, metadata) - .await - { - Ok(()) => { - indexed += 1; - } - Err(e) => { - eprintln!(" WARN: failed to index '{}': {}", doc.canonical_id, e); - failed_ids.push(doc.canonical_id.clone()); - } - } - - if (idx + 1) % 250 == 0 { - eprintln!( - " Progress: {}/{} (indexed:{} skipped:{} failed:{})", - idx + 1, - total, - indexed, - skipped_existing_count, - failed_ids.len() - ); - } - } - - eprintln!(); - eprintln!("Reprocess complete:"); - eprintln!(" Indexed: {}", indexed); - if replaced > 0 { - eprintln!(" Replaced: {}", replaced); - } - if skipped_existing_count > 0 { - eprintln!(" Skipped existing: {}", skipped_existing_count); - } - if skipped_empty_input > 0 { - eprintln!(" Skipped empty: {}", skipped_empty_input); - } - if skipped_preprocess_short > 0 { - eprintln!(" Skipped too short: {}", skipped_preprocess_short); - } - if !failed_ids.is_empty() { - eprintln!( - " FAILED: {} (IDs: {})", - failed_ids.len(), - if failed_ids.len() <= 10 { - failed_ids.join(", ") - } else { - format!( - "{}... and {} more", - failed_ids[..10].join(", "), - failed_ids.len() - 10 - ) - } - ); - } - if parse_errors > 0 { - eprintln!(" Parse errors: {}", parse_errors); - } - - Ok(()) -} - -/// Export a namespace to JSONL file for portable backup -pub async fn run_export( - namespace: String, - output: Option, - include_embeddings: bool, - db_path: String, -) -> Result<()> { - let storage = StorageManager::new_lance_only(&db_path).await?; - - const PAGE_SIZE: usize = 5000; - let mut docs: Vec = Vec::new(); - let mut offset = 0; - loop { - let page = storage - .all_documents_page(Some(&namespace), offset, PAGE_SIZE) - .await?; - let page_len = page.len(); - if page_len > 0 { - eprintln!( - " Loading: fetched {} documents (offset {})", - page_len, offset - ); - } - docs.extend(page); - if page_len < PAGE_SIZE { - break; - } - offset += page_len; - } - - if docs.is_empty() { - eprintln!("No documents found in namespace '{}'", namespace); - return Ok(()); - } - - eprintln!( - "Exporting {} documents from namespace '{}'...", - docs.len(), - namespace - ); - - // Build JSONL output - each document on a separate line - let mut lines: Vec = Vec::with_capacity(docs.len()); - - for doc in &docs { - let record = ExportRecord { - id: doc.id.clone(), - text: doc.document.clone(), - metadata: doc.metadata.clone(), - content_hash: doc.content_hash.clone(), - embeddings: if include_embeddings { - Some(doc.embedding.clone()) - } else { - None - }, - }; - - let line = serde_json::to_string(&record)?; - lines.push(line); - } - - let jsonl_content = lines.join("\n"); - - match output { - Some(path) => { - tokio::fs::write(&path, &jsonl_content).await?; - eprintln!( - "Exported {} documents from '{}' to {:?}", - docs.len(), - namespace, - path - ); - if include_embeddings { - eprintln!(" (embeddings included - file may be large)"); - } - } - None => { - println!("{}", jsonl_content); - } - } - - Ok(()) -} - -/// Import documents from JSONL file into a namespace -pub async fn run_import( - namespace: String, - input: PathBuf, - skip_existing: bool, - db_path: String, - embedding_config: &EmbeddingConfig, -) -> Result<()> { - let (_validated_input, content) = path_utils::safe_read_to_string_async(&input).await?; - let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect(); - - if lines.is_empty() { - eprintln!("No records found in input file"); - return Ok(()); - } - - eprintln!( - "Importing {} records into namespace '{}'...", - lines.len(), - namespace - ); - - // Initialize storage and embedding client - let storage = Arc::new(StorageManager::new_lance_only(&db_path).await?); - let embedding_client = Arc::new(Mutex::new(EmbeddingClient::new(embedding_config).await?)); - - let mut imported_count = 0usize; - let mut skipped_count = 0usize; - let mut error_count = 0usize; - - // Collect records that need embedding - let mut records_to_embed: Vec<(ExportRecord, usize)> = Vec::new(); - let mut records_with_embeddings: Vec<(ExportRecord, Vec)> = Vec::new(); - - // Parse all records first - for (line_num, line) in lines.iter().enumerate() { - let record: ExportRecord = match serde_json::from_str(line) { - Ok(r) => r, - Err(e) => { - eprintln!(" Line {}: parse error - {}", line_num + 1, e); - error_count += 1; - continue; - } - }; - - // Check for duplicates if skip_existing is enabled - if skip_existing - && let Some(ref hash) = record.content_hash - && storage.has_content_hash(&namespace, hash).await? - { - skipped_count += 1; - continue; - } - - // Check if record has embeddings - if record.embeddings.is_some() { - let emb = record - .embeddings - .clone() - .ok_or_else(|| anyhow!("missing embeddings"))?; - records_with_embeddings.push((record, emb)); - } else { - records_to_embed.push((record, line_num)); - } - } - - // Process records that already have embeddings - if !records_with_embeddings.is_empty() { - eprintln!( - " Storing {} records with existing embeddings...", - records_with_embeddings.len() - ); - - let mut docs = Vec::new(); - for (record, embedding) in records_with_embeddings { - let hash = record - .content_hash - .unwrap_or_else(|| compute_content_hash(&record.text)); - let doc = rust_memex::ChromaDocument::new_flat_with_hash( - record.id, - namespace.clone(), - embedding, - record.metadata, - record.text, - hash, - ); - docs.push(doc); - } - - storage.add_to_store(docs.clone()).await?; - imported_count += docs.len(); - } - - // Process records that need embedding - if !records_to_embed.is_empty() { - eprintln!( - " Re-embedding {} records without embeddings...", - records_to_embed.len() - ); - - // Batch embed texts - let texts: Vec = records_to_embed - .iter() - .map(|(r, _)| r.text.clone()) - .collect(); - let embeddings = embedding_client.lock().await.embed_batch(&texts).await?; - - let mut docs = Vec::new(); - for ((record, _line_num), embedding) in records_to_embed.into_iter().zip(embeddings) { - let hash = record - .content_hash - .unwrap_or_else(|| compute_content_hash(&record.text)); - let doc = rust_memex::ChromaDocument::new_flat_with_hash( - record.id, - namespace.clone(), - embedding, - record.metadata, - record.text, - hash, - ); - docs.push(doc); - } - - storage.add_to_store(docs.clone()).await?; - imported_count += docs.len(); - } - - eprintln!(); - eprintln!("Import complete:"); - eprintln!(" Imported: {} documents", imported_count); - if skipped_count > 0 { - eprintln!(" Skipped: {} (already exist)", skipped_count); - } - if error_count > 0 { - eprintln!(" Errors: {}", error_count); - } - - Ok(()) -} - -/// Rebuild exported JSONL documents into a fresh namespace with current chunking. -pub async fn run_reprocess( - config: ReprocessConfig, - embedding_config: &EmbeddingConfig, -) -> Result<()> { - let ReprocessConfig { - namespace, - input, - slice_mode, - preprocess, - skip_existing, - dry_run, - db_path, - } = config; - - let (_validated_input, content) = path_utils::safe_read_to_string_async(&input).await?; - let (records, parse_errors) = parse_export_records(&content); - - if records.is_empty() { - eprintln!("No valid export records found in {:?}", input); - return Ok(()); - } - - let source_records = records.len(); - let docs = collapse_export_records(records); - let source_label = input.display().to_string(); - - run_reprocess_documents( - RebuildPlan { - namespace, - db_path, - source_label, - source_records, - docs, - slice_mode, - preprocess, - skip_existing, - dry_run, - parse_errors, - }, - embedding_config, - ) - .await -} - -pub async fn run_reindex(config: ReindexConfig, embedding_config: &EmbeddingConfig) -> Result<()> { - let ReindexConfig { - source_namespace, - target_namespace, - slice_mode, - preprocess, - skip_existing, - dry_run, - db_path, - } = config; - - if source_namespace == target_namespace { - return Err(anyhow!( - "Source and target namespace are the same ('{}'). \ - In-place reindex risks data loss if the embedding server fails mid-operation. \ - Use a different target namespace, or pass --force-in-place if you have a backup.", - source_namespace - )); - } - - let storage = StorageManager::new_lance_only(&db_path).await?; - - if !dry_run { - let target_count = storage.count_namespace(&target_namespace).await?; - if target_count > 0 && !skip_existing { - return Err(anyhow!( - "Target namespace '{}' already contains {} documents. \ - Pass --skip-existing to resume, or use a fresh target namespace.", - target_namespace, - target_count - )); - } - } - - const PAGE_SIZE: usize = 5000; - let mut records: Vec = Vec::new(); - let mut offset = 0; - - loop { - let page = storage - .all_documents_page(Some(&source_namespace), offset, PAGE_SIZE) - .await?; - let page_len = page.len(); - eprintln!( - " Loading source: fetched {} documents (offset {})", - page_len, offset - ); - - for doc in page { - records.push(ExportRecord { - id: doc.id, - text: doc.document, - metadata: doc.metadata, - content_hash: doc.content_hash, - embeddings: None, - }); - } - - if page_len < PAGE_SIZE { - break; - } - offset += page_len; - } - - if records.is_empty() { - eprintln!("No documents found in namespace '{}'", source_namespace); - return Ok(()); - } - - let source_records = records.len(); - let collapsed = collapse_export_records(records); - let source_label = format!("namespace:{}@{}", source_namespace, db_path); - - run_reprocess_documents( - RebuildPlan { - namespace: target_namespace, - db_path, - source_label, - source_records, - docs: collapsed, - slice_mode, - preprocess, - skip_existing, - dry_run, - parse_errors: 0, - }, - embedding_config, - ) - .await -} - -// ============================================================================= -// AUDIT & PURGE QUALITY COMMANDS -// ============================================================================= - -/// Namespace audit result with quality metrics -#[derive(Debug, Serialize)] -pub struct NamespaceAuditResult { - pub namespace: String, - pub document_count: usize, - pub avg_chunk_length: usize, - pub sentence_integrity: f32, - pub word_integrity: f32, - pub chunk_quality: f32, - pub overall_score: f32, - pub recommendation: String, - pub passes_threshold: bool, -} - -/// Run audit on namespaces to check quality metrics -pub async fn run_audit( - namespace: Option, - threshold: u8, - verbose: bool, - json: bool, - db_path: String, -) -> Result<()> { - use rust_memex::{IntegrityRecommendation, TextIntegrityMetrics}; - - let storage = StorageManager::new_lance_only(&db_path).await?; - - // Get namespaces to audit (list_namespaces returns Vec<(String, usize)>) - let namespaces: Vec = if let Some(ns) = namespace { - vec![ns] - } else { - storage - .list_namespaces() - .await? - .into_iter() - .map(|(name, _count)| name) - .collect() - }; - - if namespaces.is_empty() { - if json { - println!(r#"{{"namespaces": [], "summary": {{"total": 0}}}}"#); - } else { - eprintln!("No namespaces found in database"); - } - return Ok(()); - } - - let threshold_f32 = threshold as f32 / 100.0; - let mut results: Vec = Vec::new(); - - if !json { - eprintln!( - "Auditing {} namespace(s) with {}% quality threshold...\n", - namespaces.len(), - threshold - ); - } - - for ns in &namespaces { - // Get all documents in namespace - let docs = storage.get_all_in_namespace(ns).await?; - - if docs.is_empty() { - results.push(NamespaceAuditResult { - namespace: ns.clone(), - document_count: 0, - avg_chunk_length: 0, - sentence_integrity: 0.0, - word_integrity: 0.0, - chunk_quality: 0.0, - overall_score: 0.0, - recommendation: "EMPTY".to_string(), - passes_threshold: false, - }); - continue; - } - - // Extract text from documents and compute metrics (ChromaDocument has `document` field) - let chunks: Vec = docs.iter().map(|d| d.document.clone()).collect(); - let combined_text = chunks.join(" "); - - let metrics = TextIntegrityMetrics::compute(&combined_text, &chunks); - let passes = metrics.overall >= threshold_f32; - - let recommendation = match metrics.recommendation() { - IntegrityRecommendation::Excellent => "EXCELLENT", - IntegrityRecommendation::Good => "GOOD", - IntegrityRecommendation::Warn => "WARN", - IntegrityRecommendation::Purge => "PURGE", - }; - - results.push(NamespaceAuditResult { - namespace: ns.clone(), - document_count: docs.len(), - avg_chunk_length: metrics.avg_chunk_length, - sentence_integrity: metrics.sentence_integrity, - word_integrity: metrics.word_integrity, - chunk_quality: metrics.chunk_quality, - overall_score: metrics.overall, - recommendation: recommendation.to_string(), - passes_threshold: passes, - }); - - if verbose && !json { - eprintln!("Namespace: {}", ns); - eprintln!(" Documents: {}", docs.len()); - eprintln!(" {}", metrics); - eprintln!(); - } - } - - // Output results - if json { - let passing = results.iter().filter(|r| r.passes_threshold).count(); - let failing = results.len() - passing; - - let output = serde_json::json!({ - "namespaces": results, - "summary": { - "total": results.len(), - "passing": passing, - "failing": failing, - "threshold": threshold - } - }); - println!("{}", serde_json::to_string_pretty(&output)?); - } else { - // Human-readable output - println!("╔════════════════════════════════════════════════════════════════╗"); - println!("║ NAMESPACE QUALITY AUDIT ║"); - println!("╠════════════════════════════════════════════════════════════════╣"); - println!( - "║ {:30} │ {:>6} │ {:>6} │ {:>8} ║", - "Namespace", "Docs", "Score", "Status" - ); - println!("╠════════════════════════════════════════════════════════════════╣"); - - for result in &results { - let status_icon = if result.passes_threshold { - "✅" - } else { - "❌" - }; - let ns_display = if result.namespace.len() > 28 { - format!("{}...", &result.namespace[..25]) - } else { - result.namespace.clone() - }; - - println!( - "║ {:30} │ {:>6} │ {:>5.1}% │ {} {:>6} ║", - ns_display, - result.document_count, - result.overall_score * 100.0, - status_icon, - result.recommendation - ); - } - - println!("╚════════════════════════════════════════════════════════════════╝"); - - let passing = results.iter().filter(|r| r.passes_threshold).count(); - let failing = results.len() - passing; - - println!(); - println!( - "Summary: {} passing, {} failing (threshold: {}%)", - passing, failing, threshold - ); - - if failing > 0 { - println!(); - println!("Namespaces below threshold:"); - for result in results.iter().filter(|r| !r.passes_threshold) { - println!( - " - {} ({:.1}% quality, {} docs)", - result.namespace, - result.overall_score * 100.0, - result.document_count - ); - } - println!(); - println!( - "Run 'rust-memex purge-quality --threshold {}' to remove low-quality namespaces", - threshold - ); - } - } - - Ok(()) -} - -/// Purge namespaces below quality threshold -pub async fn run_purge_quality( - threshold: u8, - confirm: bool, - json: bool, - db_path: String, -) -> Result<()> { - use rust_memex::TextIntegrityMetrics; - - let storage = StorageManager::new_lance_only(&db_path).await?; - // list_namespaces returns Vec<(String, usize)> - let namespace_list = storage.list_namespaces().await?; - - if namespace_list.is_empty() { - if json { - println!(r#"{{"purged": [], "dry_run": {}}}"#, !confirm); - } else { - eprintln!("No namespaces found in database"); - } - return Ok(()); - } - - let threshold_f32 = threshold as f32 / 100.0; - let mut to_purge: Vec<(String, f32, usize)> = Vec::new(); - - if !json { - eprintln!( - "Analyzing {} namespace(s) with {}% quality threshold...\n", - namespace_list.len(), - threshold - ); - } - - for (ns, _count) in &namespace_list { - let docs = storage.get_all_in_namespace(ns).await?; - - if docs.is_empty() { - to_purge.push((ns.clone(), 0.0, 0)); - continue; - } - - // ChromaDocument has `document` field, not `text` - let chunks: Vec = docs.iter().map(|d| d.document.clone()).collect(); - let combined_text = chunks.join(" "); - let metrics = TextIntegrityMetrics::compute(&combined_text, &chunks); - - if metrics.overall < threshold_f32 { - to_purge.push((ns.clone(), metrics.overall, docs.len())); - } - } - - if to_purge.is_empty() { - if json { - println!(r#"{{"purged": [], "message": "All namespaces pass quality threshold"}}"#); - } else { - println!( - "All namespaces pass the {}% quality threshold. Nothing to purge.", - threshold - ); - } - return Ok(()); - } - - if json { - let output = serde_json::json!({ - "dry_run": !confirm, - "threshold": threshold, - "to_purge": to_purge.iter().map(|(ns, score, count)| { - serde_json::json!({ - "namespace": ns, - "quality_score": score, - "document_count": count - }) - }).collect::>() - }); - println!("{}", serde_json::to_string_pretty(&output)?); - - if !confirm { - return Ok(()); - } - } else { - println!( - "Found {} namespace(s) below {}% quality threshold:", - to_purge.len(), - threshold - ); - for (ns, score, count) in &to_purge { - println!(" - {} ({:.1}% quality, {} docs)", ns, score * 100.0, count); - } - println!(); - - if !confirm { - println!("DRY RUN - No changes made."); - println!("Run with --confirm to actually delete these namespaces."); - return Ok(()); - } - } - - // Actually purge if confirmed (use purge_namespace, not delete_namespace) - let mut purged_count = 0; - for (ns, _score, count) in &to_purge { - if !json { - eprint!("Purging '{}' ({} docs)... ", ns, count); - } - - match storage.delete_namespace_documents(ns).await { - Ok(_) => { - purged_count += 1; - if !json { - eprintln!("done"); - } - } - Err(e) => { - if !json { - eprintln!("ERROR: {}", e); - } - } - } - } - - if !json { - println!(); - println!( - "Purged {} namespace(s) with quality below {}%", - purged_count, threshold - ); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn collapse_export_records_prefers_core_slice_for_same_original_id() { - let records = vec![ - ExportRecord { - id: "outer-1".to_string(), - text: "Short summary".to_string(), - metadata: json!({ - "original_id": "doc-1", - "layer": "outer", - "project": "vista" - }), - content_hash: Some("outer-hash".to_string()), - embeddings: None, - }, - ExportRecord { - id: "core-1".to_string(), - text: "Longer full document content that should win during reprocess collapse." - .to_string(), - metadata: json!({ - "original_id": "doc-1", - "layer": "core", - "project": "vista" - }), - content_hash: Some("core-hash".to_string()), - embeddings: None, - }, - ]; - - let docs = collapse_export_records(records); - assert_eq!(docs.len(), 1); - assert_eq!(docs[0].canonical_id, "doc-1"); - assert_eq!(docs[0].source_record_id, "core-1"); - assert_eq!(docs[0].collapsed_records, 2); - assert!(docs[0].text.contains("full document content")); - } - - #[test] - fn collapse_export_records_falls_back_to_doc_id_then_record_id() { - let records = vec![ - ExportRecord { - id: "record-a".to_string(), - text: "alpha".to_string(), - metadata: json!({"doc_id": "doc-a"}), - content_hash: None, - embeddings: None, - }, - ExportRecord { - id: "record-b".to_string(), - text: "beta".to_string(), - metadata: json!({}), - content_hash: None, - embeddings: None, - }, - ]; - - let docs = collapse_export_records(records); - assert_eq!(docs.len(), 2); - assert_eq!(docs[0].canonical_id, "doc-a"); - assert_eq!(docs[1].canonical_id, "record-b"); - } - - #[test] - fn prepare_reprocess_metadata_replaces_stale_chunk_fields() { - let metadata = json!({ - "original_id": "old-doc", - "layer": "outer", - "slice_mode": "onion", - "content_hash": "stale", - "project": "vista" - }); - - let prepared = prepare_reprocess_metadata( - &metadata, - "core-1", - "fresh-hash", - 4, - SliceMode::OnionFast, - "legacy.jsonl", - true, - ); - - assert_eq!(prepared["project"], "vista"); - assert_eq!(prepared["slice_mode"], "onion-fast"); - assert_eq!(prepared["reprocess_source_record_id"], "core-1"); - assert_eq!(prepared["reprocess_source_hash"], "fresh-hash"); - assert_eq!(prepared["reprocess_collapsed_records"], 4); - assert_eq!(prepared["reprocess_source"], "legacy.jsonl"); - assert_eq!(prepared["reprocess_preprocessed"], true); - assert!(prepared.get("layer").is_none()); - assert!(prepared.get("original_id").is_none()); - assert!(prepared.get("content_hash").is_none()); - } - - #[test] - fn default_reindexed_namespace_appends_suffix() { - assert_eq!( - default_reindexed_namespace("kodowanie"), - "kodowanie-reindexed" - ); - } -} diff --git a/src/security/mod.rs b/src/security/mod.rs deleted file mode 100644 index ca8037e..0000000 --- a/src/security/mod.rs +++ /dev/null @@ -1,354 +0,0 @@ -//! Security module for namespace access control. -//! -//! This module provides token-based access control for namespaces. -//! Each namespace can have an associated access token that must be provided -//! when reading or writing data to that namespace. - -use anyhow::{Result, anyhow}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::Path; -use std::sync::Arc; -use tokio::sync::RwLock; -use tracing::{debug, info, warn}; -use uuid::Uuid; - -/// Token prefix for namespace access tokens -const TOKEN_PREFIX: &str = "ns_"; - -/// Configuration for namespace security -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct NamespaceSecurityConfig { - /// Whether token-based access control is enabled - #[serde(default)] - pub enabled: bool, - /// Path to the token store file - #[serde(default)] - pub token_store_path: Option, -} - -/// Stored token information for a namespace -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NamespaceToken { - /// The namespace this token grants access to - pub namespace: String, - /// The actual token value - pub token: String, - /// When the token was created (Unix timestamp) - pub created_at: u64, - /// Optional description/label for the token - pub description: Option, -} - -/// Token store for managing namespace access tokens -#[derive(Debug)] -pub struct TokenStore { - /// Map of namespace -> token - tokens: Arc>>, - /// Path to persist tokens (if any) - store_path: Option, -} - -impl TokenStore { - /// Create a new token store - pub fn new(store_path: Option) -> Self { - Self { - tokens: Arc::new(RwLock::new(HashMap::new())), - store_path, - } - } - - /// Load tokens from persistent storage - pub async fn load(&self) -> Result<()> { - if let Some(path) = &self.store_path { - let expanded = shellexpand::tilde(path).to_string(); - let path = Path::new(&expanded); - - if path.exists() { - let contents = tokio::fs::read_to_string(path).await?; - let loaded: HashMap = serde_json::from_str(&contents)?; - let mut tokens = self.tokens.write().await; - *tokens = loaded; - info!("Loaded {} namespace tokens from {}", tokens.len(), expanded); - } - } - Ok(()) - } - - /// Save tokens to persistent storage - pub async fn save(&self) -> Result<()> { - if let Some(path) = &self.store_path { - let expanded = shellexpand::tilde(path).to_string(); - let path = Path::new(&expanded); - - // Ensure parent directory exists - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - let tokens = self.tokens.read().await; - let contents = serde_json::to_string_pretty(&*tokens)?; - tokio::fs::write(path, contents).await?; - debug!("Saved {} namespace tokens to {}", tokens.len(), expanded); - } - Ok(()) - } - - /// Generate a new token for a namespace - pub fn generate_token() -> String { - format!( - "{}{}", - TOKEN_PREFIX, - Uuid::new_v4().to_string().replace("-", "") - ) - } - - /// Create or update a token for a namespace - pub async fn create_token( - &self, - namespace: &str, - description: Option, - ) -> Result { - let token = Self::generate_token(); - let namespace_token = NamespaceToken { - namespace: namespace.to_string(), - token: token.clone(), - created_at: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - description, - }; - - { - let mut tokens = self.tokens.write().await; - tokens.insert(namespace.to_string(), namespace_token); - } - - self.save().await?; - info!("Created token for namespace '{}'", namespace); - Ok(token) - } - - /// Verify a token for a namespace - pub async fn verify_token(&self, namespace: &str, token: &str) -> bool { - let tokens = self.tokens.read().await; - if let Some(stored) = tokens.get(namespace) { - stored.token == token - } else { - // If no token is set for this namespace, access is allowed - // (backward compatibility - namespaces without tokens are open) - true - } - } - - /// Check if a namespace has a token set - pub async fn has_token(&self, namespace: &str) -> bool { - let tokens = self.tokens.read().await; - tokens.contains_key(namespace) - } - - /// Get token info for a namespace (without revealing the actual token) - pub async fn get_token_info(&self, namespace: &str) -> Option<(u64, Option)> { - let tokens = self.tokens.read().await; - tokens - .get(namespace) - .map(|t| (t.created_at, t.description.clone())) - } - - /// Revoke (delete) a token for a namespace - pub async fn revoke_token(&self, namespace: &str) -> Result { - let removed = { - let mut tokens = self.tokens.write().await; - tokens.remove(namespace).is_some() - }; - - if removed { - self.save().await?; - info!("Revoked token for namespace '{}'", namespace); - } - - Ok(removed) - } - - /// List all namespaces that have tokens (without revealing tokens) - pub async fn list_protected_namespaces(&self) -> Vec<(String, u64, Option)> { - let tokens = self.tokens.read().await; - tokens - .values() - .map(|t| (t.namespace.clone(), t.created_at, t.description.clone())) - .collect() - } -} - -/// Namespace access manager that combines token verification with access control -#[derive(Debug)] -pub struct NamespaceAccessManager { - /// Token store for managing tokens - token_store: TokenStore, - /// Whether token-based access control is enabled - enabled: bool, -} - -impl NamespaceAccessManager { - /// Create a new namespace access manager - pub fn new(config: NamespaceSecurityConfig) -> Self { - let store_path = config.token_store_path.or_else(|| { - if config.enabled { - Some("~/.rmcp-servers/rust-memex/tokens.json".to_string()) - } else { - None - } - }); - - Self { - token_store: TokenStore::new(store_path), - enabled: config.enabled, - } - } - - /// Initialize the access manager (load tokens from storage) - pub async fn init(&self) -> Result<()> { - if self.enabled { - self.token_store.load().await?; - } - Ok(()) - } - - /// Check if access control is enabled - pub fn is_enabled(&self) -> bool { - self.enabled - } - - /// Verify access to a namespace - /// Returns Ok(()) if access is granted, Err if denied - pub async fn verify_access(&self, namespace: &str, token: Option<&str>) -> Result<()> { - if !self.enabled { - return Ok(()); - } - - // Check if namespace has a token - if !self.token_store.has_token(namespace).await { - // No token set for this namespace - allow access - return Ok(()); - } - - // Namespace has a token - verify it - match token { - Some(t) => { - if self.token_store.verify_token(namespace, t).await { - Ok(()) - } else { - warn!("Invalid token provided for namespace '{}'", namespace); - Err(anyhow!( - "Access denied: invalid token for namespace '{}'", - namespace - )) - } - } - None => { - warn!("No token provided for protected namespace '{}'", namespace); - Err(anyhow!( - "Access denied: namespace '{}' requires a token. Use namespace_create_token to generate one.", - namespace - )) - } - } - } - - /// Create a token for a namespace - pub async fn create_token( - &self, - namespace: &str, - description: Option, - ) -> Result { - self.token_store.create_token(namespace, description).await - } - - /// Revoke a token for a namespace - pub async fn revoke_token(&self, namespace: &str) -> Result { - self.token_store.revoke_token(namespace).await - } - - /// List protected namespaces - pub async fn list_protected_namespaces(&self) -> Vec<(String, u64, Option)> { - self.token_store.list_protected_namespaces().await - } - - /// Get token info for a namespace - pub async fn get_token_info(&self, namespace: &str) -> Option<(u64, Option)> { - self.token_store.get_token_info(namespace).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_token_generation() { - let token = TokenStore::generate_token(); - assert!(token.starts_with(TOKEN_PREFIX)); - assert!(token.len() > TOKEN_PREFIX.len()); - } - - #[tokio::test] - async fn test_token_store_create_and_verify() { - let store = TokenStore::new(None); - - let token = store - .create_token("test_namespace", Some("Test token".to_string())) - .await - .unwrap(); - - assert!(store.verify_token("test_namespace", &token).await); - assert!(!store.verify_token("test_namespace", "wrong_token").await); - assert!(store.verify_token("other_namespace", "any_token").await); // No token set - } - - #[tokio::test] - async fn test_access_manager_disabled() { - let config = NamespaceSecurityConfig::default(); - let manager = NamespaceAccessManager::new(config); - - // When disabled, all access should be allowed - assert!(manager.verify_access("any_namespace", None).await.is_ok()); - } - - #[tokio::test] - async fn test_access_manager_enabled() { - let config = NamespaceSecurityConfig { - enabled: true, - token_store_path: None, - }; - let manager = NamespaceAccessManager::new(config); - - // Create a token for a namespace - let token = manager - .create_token("protected", Some("Test".to_string())) - .await - .unwrap(); - - // Access without token should fail - assert!(manager.verify_access("protected", None).await.is_err()); - - // Access with wrong token should fail - assert!( - manager - .verify_access("protected", Some("wrong")) - .await - .is_err() - ); - - // Access with correct token should succeed - assert!( - manager - .verify_access("protected", Some(&token)) - .await - .is_ok() - ); - - // Unprotected namespace should allow access without token - assert!(manager.verify_access("unprotected", None).await.is_ok()); - } -} diff --git a/tests/e2e_pipeline.rs b/tests/e2e_pipeline.rs deleted file mode 100644 index d8ed679..0000000 --- a/tests/e2e_pipeline.rs +++ /dev/null @@ -1,434 +0,0 @@ -//! End-to-End Pipeline Tests -//! -//! These tests verify the full pipeline: index → embed → search -//! They require a running embedding server (Ollama or MLX). -//! -//! Run with: cargo test --test e2e_pipeline -- --ignored -//! -//! Vibecrafted with AI Agents by Loctree (c)2026 Loctree - -use rust_memex::{ - ChromaDocument, EmbeddingClient, EmbeddingConfig, ProviderConfig, StorageManager, -}; -use serde::Deserialize; -use serde_json::json; -use tempfile::TempDir; - -const LOCAL_OLLAMA_MODEL: &str = "qwen3-embedding:4b"; -const LOCAL_OLLAMA_DIMENSION: usize = 2560; - -// ============================================================================= -// HELPER: Check if embedding server is available -// ============================================================================= - -#[derive(Debug, Deserialize)] -struct OllamaTagsResponse { - models: Vec, -} - -#[derive(Debug, Deserialize)] -struct OllamaModel { - name: String, -} - -async fn ollama_qwen4b_available() -> bool { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build() - .unwrap(); - - if let Ok(resp) = client.get("http://localhost:11434/api/tags").send().await { - if !resp.status().is_success() { - return false; - } - - if let Ok(tags) = resp.json::().await { - return tags - .models - .iter() - .any(|model| model.name == LOCAL_OLLAMA_MODEL); - } - } - - false -} - -fn create_test_embedding_config() -> EmbeddingConfig { - EmbeddingConfig { - required_dimension: LOCAL_OLLAMA_DIMENSION, - max_batch_chars: 32000, - max_batch_items: 16, - providers: vec![ProviderConfig { - name: "ollama-local".to_string(), - base_url: "http://localhost:11434".to_string(), - model: LOCAL_OLLAMA_MODEL.to_string(), - priority: 1, - endpoint: "/v1/embeddings".to_string(), - }], - reranker: Default::default(), - } -} - -// ============================================================================= -// E2E TEST: Full pipeline index → embed → search -// ============================================================================= - -/// Full E2E test: create embeddings, store, search, verify results -#[tokio::test] -#[ignore] // Run with: cargo test --test e2e_pipeline -- --ignored -async fn test_e2e_index_embed_search() { - if !ollama_qwen4b_available().await { - eprintln!( - "SKIP: Local Ollama model '{}' unavailable at http://localhost:11434", - LOCAL_OLLAMA_MODEL - ); - return; - } - - // Setup - let tmp = TempDir::new().expect("Failed to create temp dir"); - let db_path = tmp.path().join("lancedb"); - - let config = create_test_embedding_config(); - let mut embedder = EmbeddingClient::new(&config) - .await - .expect("Failed to create embedding client"); - - let storage = StorageManager::new_lance_only(db_path.to_str().unwrap()) - .await - .expect("Failed to create storage"); - - storage - .ensure_collection() - .await - .expect("Failed to ensure collection"); - - // Test documents - let test_docs = vec![ - ( - "doc-rust", - "Rust is a systems programming language focused on safety and performance.", - ), - ( - "doc-python", - "Python is a high-level interpreted language known for its readability.", - ), - ( - "doc-javascript", - "JavaScript is the language of the web, running in browsers and Node.js.", - ), - ( - "doc-veterinary", - "Veterinary medicine involves the diagnosis and treatment of animals.", - ), - ( - "doc-ai", - "Artificial intelligence enables machines to learn from experience.", - ), - ]; - - // INDEX: Generate embeddings and store documents - let mut stored_docs = Vec::new(); - for (id, content) in &test_docs { - let embedding = embedder - .embed(content) - .await - .expect("Failed to generate embedding"); - - assert_eq!( - embedding.len(), - LOCAL_OLLAMA_DIMENSION, - "Embedding dimension should be {}", - LOCAL_OLLAMA_DIMENSION - ); - - let doc = ChromaDocument::new_flat( - id.to_string(), - "e2e-test-ns".to_string(), - embedding, - json!({"type": "programming", "source": "test"}), - content.to_string(), - ); - stored_docs.push(doc); - } - - storage - .add_to_store(stored_docs) - .await - .expect("Failed to store documents"); - - // SEARCH: Query for programming languages - let query = "systems programming language with memory safety"; - let query_embedding = embedder.embed(query).await.expect("Failed to embed query"); - - let results = storage - .search_store(Some("e2e-test-ns"), query_embedding, 3) - .await - .expect("Failed to search"); - - // VERIFY: Should find Rust as top result (most relevant to query) - assert!(!results.is_empty(), "Search should return results"); - assert!(results.len() <= 3, "Should respect limit"); - - // Rust should be in top results for memory safety query - let result_ids: Vec<&str> = results.iter().map(|d| d.id.as_str()).collect(); - eprintln!("Search results for '{}': {:?}", query, result_ids); - - // At minimum, we should get some results - assert!( - results.iter().any(|d| d.id.starts_with("doc-")), - "Results should contain our test documents" - ); -} - -/// Test batch embedding with multiple texts -#[tokio::test] -#[ignore] -async fn test_e2e_batch_embedding() { - if !ollama_qwen4b_available().await { - eprintln!( - "SKIP: Local Ollama model '{}' unavailable", - LOCAL_OLLAMA_MODEL - ); - return; - } - - let config = create_test_embedding_config(); - let mut embedder = EmbeddingClient::new(&config) - .await - .expect("Failed to create embedding client"); - - let texts: Vec = vec![ - "First document about machine learning".to_string(), - "Second document about natural language processing".to_string(), - "Third document about computer vision".to_string(), - "Fourth document about reinforcement learning".to_string(), - ]; - - let embeddings = embedder - .embed_batch(&texts) - .await - .expect("Failed to batch embed"); - - assert_eq!( - embeddings.len(), - texts.len(), - "Should get embedding for each text" - ); - - for (i, emb) in embeddings.iter().enumerate() { - assert_eq!( - emb.len(), - LOCAL_OLLAMA_DIMENSION, - "Embedding {} should have {} dimensions", - i, - LOCAL_OLLAMA_DIMENSION - ); - - // Check no NaN/Inf values - for (j, &val) in emb.iter().enumerate() { - assert!( - !val.is_nan() && !val.is_infinite(), - "Embedding {} has invalid value at index {}: {}", - i, - j, - val - ); - } - } -} - -/// Test dimension validation at startup (fail-fast) -#[tokio::test] -#[ignore] -async fn test_e2e_local_ollama_qwen4b_validation() { - if !ollama_qwen4b_available().await { - eprintln!( - "SKIP: Local Ollama model '{}' unavailable", - LOCAL_OLLAMA_MODEL - ); - return; - } - - // Config with correct dimension - let config = create_test_embedding_config(); - let result = EmbeddingClient::new(&config).await; - assert!( - result.is_ok(), - "Should connect with correct dimension config" - ); - - let mut embedder = result.unwrap(); - let connected = embedder.connected_to(); - eprintln!("Connected to: {}", connected); - - assert!( - connected == "ollama-local", - "Should connect to local Ollama, got: {}", - connected - ); - - let embedding = embedder - .embed("qwen4b validation probe") - .await - .expect("Expected local Ollama /v1/embeddings to work"); - assert_eq!(embedding.len(), LOCAL_OLLAMA_DIMENSION); - - let mut bad_config = create_test_embedding_config(); - bad_config.required_dimension = 4096; - let err = EmbeddingClient::new(&bad_config) - .await - .err() - .expect("Mismatched config dimension should fail fast"); - let message = err.to_string(); - assert!( - message.contains("returned 2560 dims") || message.contains("returned 2560"), - "Unexpected error message: {}", - message - ); - assert!( - message.contains("required_dimension=4096"), - "Unexpected error message: {}", - message - ); -} - -/// Test deduplication via content hash -#[tokio::test] -#[ignore] -async fn test_e2e_deduplication() { - if !ollama_qwen4b_available().await { - eprintln!( - "SKIP: Local Ollama model '{}' unavailable", - LOCAL_OLLAMA_MODEL - ); - return; - } - - use rust_memex::compute_content_hash; - - let tmp = TempDir::new().expect("Failed to create temp dir"); - let db_path = tmp.path().join("lancedb"); - - let config = create_test_embedding_config(); - let mut embedder = EmbeddingClient::new(&config) - .await - .expect("Failed to create embedding client"); - - let storage = StorageManager::new_lance_only(db_path.to_str().unwrap()) - .await - .expect("Failed to create storage"); - - storage.ensure_collection().await.unwrap(); - - let content = "This is unique content for deduplication test"; - let hash = compute_content_hash(content); - - // First check - hash should not exist - let exists_before = storage - .has_content_hash("dedup-test-ns", &hash) - .await - .expect("Failed to check hash"); - assert!(!exists_before, "Hash should not exist before indexing"); - - // Index the content - let embedding = embedder.embed(content).await.unwrap(); - let mut doc = ChromaDocument::new_flat( - "dedup-doc-1".to_string(), - "dedup-test-ns".to_string(), - embedding, - json!({}), - content.to_string(), - ); - doc.content_hash = Some(hash.clone()); - storage.add_to_store(vec![doc]).await.unwrap(); - - // Second check - hash should exist now - let exists_after = storage - .has_content_hash("dedup-test-ns", &hash) - .await - .expect("Failed to check hash"); - assert!(exists_after, "Hash should exist after indexing"); -} - -/// Test that invalid embeddings are rejected -#[tokio::test] -async fn test_storage_rejects_invalid_embeddings() { - let tmp = TempDir::new().expect("Failed to create temp dir"); - let db_path = tmp.path().join("lancedb"); - - let storage = StorageManager::new_lance_only(db_path.to_str().unwrap()) - .await - .expect("Failed to create storage"); - - storage.ensure_collection().await.unwrap(); - - // Test: Empty ID should be rejected - let doc_empty_id = ChromaDocument::new_flat( - "".to_string(), - "test-ns".to_string(), - vec![0.1f32; LOCAL_OLLAMA_DIMENSION], - json!({}), - "Content".to_string(), - ); - let result = storage.add_to_store(vec![doc_empty_id]).await; - assert!(result.is_err(), "Empty ID should be rejected"); - - // Test: Empty namespace should be rejected - let doc_empty_ns = ChromaDocument::new_flat( - "valid-id".to_string(), - "".to_string(), - vec![0.1f32; LOCAL_OLLAMA_DIMENSION], - json!({}), - "Content".to_string(), - ); - let result = storage.add_to_store(vec![doc_empty_ns]).await; - assert!(result.is_err(), "Empty namespace should be rejected"); - - // Test: NaN in embedding should be rejected - let mut embedding_with_nan = vec![0.1f32; LOCAL_OLLAMA_DIMENSION]; - embedding_with_nan[100] = f32::NAN; - let doc_nan = ChromaDocument::new_flat( - "nan-doc".to_string(), - "test-ns".to_string(), - embedding_with_nan, - json!({}), - "Content".to_string(), - ); - let result = storage.add_to_store(vec![doc_nan]).await; - assert!(result.is_err(), "NaN in embedding should be rejected"); - - // Test: Inf in embedding should be rejected - let mut embedding_with_inf = vec![0.1f32; LOCAL_OLLAMA_DIMENSION]; - embedding_with_inf[200] = f32::INFINITY; - let doc_inf = ChromaDocument::new_flat( - "inf-doc".to_string(), - "test-ns".to_string(), - embedding_with_inf, - json!({}), - "Content".to_string(), - ); - let result = storage.add_to_store(vec![doc_inf]).await; - assert!(result.is_err(), "Inf in embedding should be rejected"); - - // Test: Inconsistent dimensions in batch should be rejected - let doc_good = ChromaDocument::new_flat( - "doc-good".to_string(), - "test-ns".to_string(), - vec![0.1f32; LOCAL_OLLAMA_DIMENSION], - json!({}), - "Content".to_string(), - ); - let doc_1024 = ChromaDocument::new_flat( - "doc-1024".to_string(), - "test-ns".to_string(), - vec![0.1f32; 1024], // Wrong dimension! - json!({}), - "Content".to_string(), - ); - let result = storage.add_to_store(vec![doc_good, doc_1024]).await; - assert!( - result.is_err(), - "Inconsistent dimensions should be rejected" - ); -} diff --git a/tools/githooks/pre-commit b/tools/githooks/pre-commit index d717cda..48ac1dd 100755 --- a/tools/githooks/pre-commit +++ b/tools/githooks/pre-commit @@ -4,6 +4,27 @@ set -e REPO_ROOT=$(git rev-parse --show-toplevel) cd "$REPO_ROOT" +setup_rust_toolchain_path() { + if [ -x "$HOME/.cargo/bin/rustup" ]; then + export PATH="$HOME/.cargo/bin:$PATH" + return + fi + + if [ -x "/opt/homebrew/opt/rustup/bin/rustup" ]; then + export PATH="/opt/homebrew/opt/rustup/bin:$PATH" + return + fi + + for toolchain_bin in "$HOME"/.rustup/toolchains/stable-*/bin; do + if [ -x "$toolchain_bin/rustc" ]; then + export PATH="$toolchain_bin:$PATH" + return + fi + done +} + +setup_rust_toolchain_path + echo "=== Pre-commit (fast checks) ===" # Check if any Rust files are staged diff --git a/tools/githooks/pre-push b/tools/githooks/pre-push index e07d17c..a4f2049 100755 --- a/tools/githooks/pre-push +++ b/tools/githooks/pre-push @@ -4,6 +4,27 @@ set -e REPO_ROOT=$(git rev-parse --show-toplevel) cd "$REPO_ROOT" +setup_rust_toolchain_path() { + if [ -x "$HOME/.cargo/bin/rustup" ]; then + export PATH="$HOME/.cargo/bin:$PATH" + return + fi + + if [ -x "/opt/homebrew/opt/rustup/bin/rustup" ]; then + export PATH="/opt/homebrew/opt/rustup/bin:$PATH" + return + fi + + for toolchain_bin in "$HOME"/.rustup/toolchains/stable-*/bin; do + if [ -x "$toolchain_bin/rustc" ]; then + export PATH="$toolchain_bin:$PATH" + return + fi + done +} + +setup_rust_toolchain_path + echo "=== Pre-push validation ===" remote="$1" diff --git a/tools/install-githooks.sh b/tools/install-githooks.sh index c8e68ba..055fc38 100755 --- a/tools/install-githooks.sh +++ b/tools/install-githooks.sh @@ -3,14 +3,17 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" HOOKS_DIR="$ROOT/.git/hooks" -SRC="$ROOT/tools/githooks/pre-commit" +PRE_COMMIT_SRC="$ROOT/tools/githooks/pre-commit" +PRE_PUSH_SRC="$ROOT/tools/githooks/pre-push" if [[ ! -d "$HOOKS_DIR" ]]; then echo "No .git/hooks directory found. Are you in a git repo?" >&2 exit 1 fi -chmod +x "$SRC" -ln -sf "$SRC" "$HOOKS_DIR/pre-commit" +chmod +x "$PRE_COMMIT_SRC" "$PRE_PUSH_SRC" +ln -sf "$PRE_COMMIT_SRC" "$HOOKS_DIR/pre-commit" +ln -sf "$PRE_PUSH_SRC" "$HOOKS_DIR/pre-push" echo "Installed pre-commit hook -> $HOOKS_DIR/pre-commit" +echo "Installed pre-push hook -> $HOOKS_DIR/pre-push"